- {#each [0.6, 0.75, 0.5] as width}
+ {#each [0.6, 0.75, 0.5] as width (width)}
void;
diff --git a/web-common/src/features/query/QueryEditor.svelte b/web-common/src/features/query/QueryEditor.svelte
new file mode 100644
index 00000000000..25372ecc42a
--- /dev/null
+++ b/web-common/src/features/query/QueryEditor.svelte
@@ -0,0 +1,143 @@
+
+
+
+
+
diff --git a/web-common/src/features/query/QueryResultsTable.svelte b/web-common/src/features/query/QueryResultsTable.svelte
new file mode 100644
index 00000000000..3476bb3070f
--- /dev/null
+++ b/web-common/src/features/query/QueryResultsTable.svelte
@@ -0,0 +1,48 @@
+
+
+{#if rows.length > 0 && columns.length > 0}
+
+{:else if error}
+
+
Query failed. Review the error above and try again.
+
+{:else if hasExecuted}
+
+{:else}
+
+
Run a query to view results
+
+{/if}
+
+
diff --git a/web-common/src/features/query/QueryWorkspace.svelte b/web-common/src/features/query/QueryWorkspace.svelte
new file mode 100644
index 00000000000..1b6e76c766b
--- /dev/null
+++ b/web-common/src/features/query/QueryWorkspace.svelte
@@ -0,0 +1,334 @@
+
+
+
+ {#if worksheet}
+
+ {#if showTables}
+
+
+
+ {/if}
+
+
+
+
+
+
+ worksheet?.setSql(sql)}
+ onrun={execute}
+ />
+
+
+ {#if worksheetError}
+
+
+ {worksheetError}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ {/if}
+
+
+
diff --git a/web-common/src/features/query/query-store.spec.ts b/web-common/src/features/query/query-store.spec.ts
new file mode 100644
index 00000000000..c50ba76b9da
--- /dev/null
+++ b/web-common/src/features/query/query-store.spec.ts
@@ -0,0 +1,176 @@
+import { get } from "svelte/store";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { V1QueryResolverResponse } from "@rilldata/web-common/runtime-client";
+import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2";
+import type { SqlExecution } from "./query-utils";
+
+vi.mock("@rilldata/web-common/runtime-client/v2/gen/runtime-service", () => ({
+ runtimeServiceQueryResolver: vi.fn(),
+}));
+
+import { runtimeServiceQueryResolver } from "@rilldata/web-common/runtime-client/v2/gen/runtime-service";
+import {
+ clearSqlWorksheetCache,
+ createSqlWorksheet,
+ SQL_CONSOLE_ROW_LIMIT,
+} from "./query-store";
+
+const client = { instanceId: "instance" } as RuntimeClient;
+const execution: SqlExecution = {
+ sql: "SELECT 42",
+ from: 8,
+ to: 17,
+ startLine: 2,
+ endLine: 2,
+};
+
+describe("createSqlWorksheet", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ clearSqlWorksheetCache();
+ });
+
+ it("starts with an empty worksheet", () => {
+ const worksheet = createSqlWorksheet("duckdb", "acme/analytics");
+
+ expect(get(worksheet)).toEqual({
+ sql: "",
+ isExecuting: false,
+ hasExecuted: false,
+ result: null,
+ error: null,
+ executionTimeMs: null,
+ lastExecution: null,
+ });
+ });
+
+ it("restores worksheet text from the in-memory cache", () => {
+ const worksheet = createSqlWorksheet("duckdb", "acme/analytics");
+ worksheet.setSql("SELECT * FROM events");
+ worksheet.destroy();
+
+ const restored = createSqlWorksheet("duckdb", "acme/analytics");
+
+ expect(get(restored).sql).toBe("SELECT * FROM events");
+ expect(localStorage.length).toBe(0);
+ });
+
+ it("separates cached worksheets by project and branch", () => {
+ const production = createSqlWorksheet("duckdb", "acme/analytics");
+ const branch = createSqlWorksheet("duckdb", "acme/analytics@new-metrics");
+ production.setSql("SELECT 'production'");
+ branch.setSql("SELECT 'branch'");
+
+ expect(get(createSqlWorksheet("duckdb", "acme/analytics")).sql).toBe(
+ "SELECT 'production'",
+ );
+ expect(
+ get(createSqlWorksheet("duckdb", "acme/analytics@new-metrics")).sql,
+ ).toBe("SELECT 'branch'");
+ });
+
+ it("limits worksheet results to 1,000 rows", async () => {
+ const result: V1QueryResolverResponse = {
+ schema: { fields: [{ name: "answer" }] },
+ data: [{ answer: 42 }],
+ };
+ vi.mocked(runtimeServiceQueryResolver).mockResolvedValue(result);
+ const worksheet = createSqlWorksheet("clickhouse", "acme/analytics");
+
+ await worksheet.execute(client, execution);
+
+ const [calledClient, request, options] = vi.mocked(
+ runtimeServiceQueryResolver,
+ ).mock.calls[0];
+ expect(calledClient).toBe(client);
+ expect(request).toMatchObject({
+ resolver: "sql",
+ resolverProperties: {
+ sql: "SELECT 42",
+ connector: "clickhouse",
+ },
+ });
+ expect(request.limit).toBe(SQL_CONSOLE_ROW_LIMIT);
+ expect(options?.signal).toBeInstanceOf(AbortSignal);
+ expect(get(worksheet)).toMatchObject({
+ result,
+ hasExecuted: true,
+ isExecuting: false,
+ error: null,
+ lastExecution: execution,
+ });
+ });
+
+ it("does not classify SQL on the client", async () => {
+ vi.mocked(runtimeServiceQueryResolver).mockRejectedValue(
+ new Error("statement rejected by runtime"),
+ );
+ const worksheet = createSqlWorksheet("duckdb", "acme/analytics");
+
+ await worksheet.execute(client, {
+ ...execution,
+ sql: "DELETE FROM events",
+ });
+
+ expect(runtimeServiceQueryResolver).toHaveBeenCalledOnce();
+ expect(get(worksheet).error).toBe("statement rejected by runtime");
+ });
+
+ it("retains the last successful result when a later run fails", async () => {
+ const result: V1QueryResolverResponse = { data: [{ value: 1 }] };
+ vi.mocked(runtimeServiceQueryResolver)
+ .mockResolvedValueOnce(result)
+ .mockRejectedValueOnce(new Error("query failed"));
+ const worksheet = createSqlWorksheet("duckdb", "acme/analytics");
+
+ await worksheet.execute(client, execution);
+ const executionTimeMs = get(worksheet).executionTimeMs;
+ await worksheet.execute(client, { ...execution, sql: "SELECT broken" });
+
+ // The retained result keeps its own execution time; the failed run does not
+ // overwrite it, so the results header stays consistent with the table.
+ expect(get(worksheet)).toMatchObject({
+ result,
+ error: "query failed",
+ lastExecution: execution,
+ executionTimeMs,
+ });
+ });
+
+ it("cancels an in-flight query", async () => {
+ vi.mocked(runtimeServiceQueryResolver).mockImplementation(
+ (_client, _request, options) =>
+ new Promise((_resolve, reject) => {
+ options?.signal?.addEventListener("abort", () =>
+ reject(new DOMException("Aborted", "AbortError")),
+ );
+ }),
+ );
+ const worksheet = createSqlWorksheet("duckdb", "acme/analytics");
+
+ const request = worksheet.execute(client, execution);
+ expect(get(worksheet).isExecuting).toBe(true);
+ worksheet.cancel();
+ await request;
+
+ expect(get(worksheet).isExecuting).toBe(false);
+ expect(get(worksheet).error).toBeNull();
+ });
+
+ it("does not cache query results across navigation", async () => {
+ vi.mocked(runtimeServiceQueryResolver).mockResolvedValue({
+ data: [{ answer: 42 }],
+ });
+ const worksheet = createSqlWorksheet("duckdb", "acme/analytics");
+ worksheet.setSql("SELECT 42");
+ await worksheet.execute(client, execution);
+ worksheet.destroy();
+
+ const restored = createSqlWorksheet("duckdb", "acme/analytics");
+
+ expect(get(restored).sql).toBe("SELECT 42");
+ expect(get(restored).result).toBeNull();
+ expect(get(restored).hasExecuted).toBe(false);
+ });
+});
diff --git a/web-common/src/features/query/query-store.ts b/web-common/src/features/query/query-store.ts
new file mode 100644
index 00000000000..e6b6e7a1701
--- /dev/null
+++ b/web-common/src/features/query/query-store.ts
@@ -0,0 +1,116 @@
+import type { PartialMessage, Struct } from "@bufbuild/protobuf";
+import { browser } from "$app/environment";
+import { get, writable } from "svelte/store";
+import { extractErrorMessage } from "@rilldata/web-common/lib/errors";
+import type { V1QueryResolverResponse } from "@rilldata/web-common/runtime-client";
+import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2";
+import { runtimeServiceQueryResolver } from "@rilldata/web-common/runtime-client/v2/gen/runtime-service";
+import type { SqlExecution } from "./query-utils";
+
+export interface SqlWorksheetState {
+ sql: string;
+ isExecuting: boolean;
+ hasExecuted: boolean;
+ result: V1QueryResolverResponse | null;
+ error: string | null;
+ executionTimeMs: number | null;
+ lastExecution: SqlExecution | null;
+}
+
+export const SQL_CONSOLE_ROW_LIMIT = 1_000;
+
+const worksheetCache = new Map
();
+
+export function createSqlWorksheet(connector: string, cacheKey: string) {
+ const state = writable({
+ sql: browser ? (worksheetCache.get(cacheKey) ?? "") : "",
+ isExecuting: false,
+ hasExecuted: false,
+ result: null,
+ error: null,
+ executionTimeMs: null,
+ lastExecution: null,
+ });
+ let abortController: AbortController | null = null;
+
+ function setSql(sql: string) {
+ state.update(($state) => ({ ...$state, sql }));
+ if (browser) worksheetCache.set(cacheKey, sql);
+ }
+
+ async function execute(client: RuntimeClient, execution: SqlExecution) {
+ if (get(state).isExecuting || !execution.sql.trim()) return;
+
+ abortController = new AbortController();
+ state.update(($state) => ({
+ ...$state,
+ isExecuting: true,
+ error: null,
+ }));
+ const startedAt = performance.now();
+ const currentController = abortController;
+
+ try {
+ const result = await runtimeServiceQueryResolver(
+ client,
+ {
+ resolver: "sql",
+ resolverProperties: {
+ sql: execution.sql,
+ connector,
+ } as unknown as PartialMessage,
+ limit: SQL_CONSOLE_ROW_LIMIT,
+ },
+ { signal: currentController.signal },
+ );
+
+ state.update(($state) => ({
+ ...$state,
+ isExecuting: false,
+ hasExecuted: true,
+ result,
+ error: null,
+ executionTimeMs: Math.round(performance.now() - startedAt),
+ lastExecution: execution,
+ }));
+ } catch (error) {
+ if (currentController.signal.aborted) return;
+
+ // Leave result and executionTimeMs untouched so the last successful
+ // results stay visible below the error banner.
+ state.update(($state) => ({
+ ...$state,
+ isExecuting: false,
+ hasExecuted: true,
+ error: extractErrorMessage(error),
+ }));
+ } finally {
+ if (abortController === currentController) abortController = null;
+ }
+ }
+
+ function cancel() {
+ abortController?.abort();
+ abortController = null;
+ state.update(($state) => ({ ...$state, isExecuting: false }));
+ }
+
+ function destroy() {
+ abortController?.abort();
+ abortController = null;
+ }
+
+ return {
+ subscribe: state.subscribe,
+ setSql,
+ execute,
+ cancel,
+ destroy,
+ };
+}
+
+export type SqlWorksheet = ReturnType;
+
+export function clearSqlWorksheetCache() {
+ worksheetCache.clear();
+}
diff --git a/web-common/src/features/query/query-utils.spec.ts b/web-common/src/features/query/query-utils.spec.ts
new file mode 100644
index 00000000000..82cfc660835
--- /dev/null
+++ b/web-common/src/features/query/query-utils.spec.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from "vitest";
+import { getStatementRange, trimRange } from "./query-utils";
+
+function statementAt(sql: string, cursor: number) {
+ const range = getStatementRange(sql, cursor);
+ return range ? sql.slice(range.from, range.to) : null;
+}
+
+describe("getStatementRange", () => {
+ it("returns a single statement without requiring a semicolon", () => {
+ const sql = "\n SELECT * FROM events \n";
+
+ expect(statementAt(sql, sql.indexOf("events"))).toBe(
+ "SELECT * FROM events",
+ );
+ });
+
+ it("returns the statement containing the cursor", () => {
+ const sql = "SELECT 1;\n\nSELECT 2;\nSELECT 3";
+
+ expect(statementAt(sql, sql.indexOf("2"))).toBe("SELECT 2;");
+ });
+
+ it("ignores semicolons inside strings and quoted identifiers", () => {
+ const sql = `SELECT ';' AS value, "semi;colon" AS "name;";\nSELECT 2;`;
+
+ expect(statementAt(sql, sql.indexOf("value"))).toBe(
+ `SELECT ';' AS value, "semi;colon" AS "name;";`,
+ );
+ });
+
+ it("ignores semicolons inside comments", () => {
+ const sql = `-- explain; this query\nSELECT 1 /* ; */;\nSELECT 2;`;
+
+ expect(statementAt(sql, sql.indexOf("1"))).toBe(
+ "-- explain; this query\nSELECT 1 /* ; */;",
+ );
+ });
+
+ it("ignores semicolons inside dollar-quoted strings", () => {
+ const sql = "SELECT $$one;two$$ AS value;\nSELECT 2;";
+
+ expect(statementAt(sql, sql.indexOf("value"))).toBe(
+ "SELECT $$one;two$$ AS value;",
+ );
+ });
+
+ it("falls back to the previous statement from trailing whitespace", () => {
+ const sql = "SELECT 1;\n\n";
+
+ expect(statementAt(sql, sql.length)).toBe("SELECT 1;");
+ });
+
+ it("returns null for an empty worksheet", () => {
+ expect(getStatementRange(" \n", 1)).toBeNull();
+ });
+});
+
+describe("trimRange", () => {
+ it("trims selected whitespace without changing the selected SQL", () => {
+ const sql = "before SELECT 1; after";
+
+ expect(trimRange(sql, { from: 6, to: 19 })).toEqual({
+ from: 8,
+ to: 17,
+ });
+ });
+});
diff --git a/web-common/src/features/query/query-utils.ts b/web-common/src/features/query/query-utils.ts
new file mode 100644
index 00000000000..f31b61c9432
--- /dev/null
+++ b/web-common/src/features/query/query-utils.ts
@@ -0,0 +1,181 @@
+export interface TextRange {
+ from: number;
+ to: number;
+}
+
+export interface SqlExecution extends TextRange {
+ sql: string;
+ startLine: number;
+ endLine: number;
+}
+
+/**
+ * Finds the semicolon-delimited SQL statement containing the cursor.
+ * Semicolons inside comments and quoted values are ignored. This only chooses
+ * text to execute; it does not validate or classify the SQL.
+ */
+export function getStatementRange(
+ sql: string,
+ cursor: number,
+): TextRange | null {
+ const ranges: TextRange[] = [];
+ let start = 0;
+ let index = 0;
+ let state:
+ | "normal"
+ | "single-quote"
+ | "double-quote"
+ | "backtick"
+ | "line-comment"
+ | "block-comment" = "normal";
+ let dollarQuote = "";
+
+ while (index < sql.length) {
+ const character = sql[index];
+ const nextCharacter = sql[index + 1];
+
+ if (dollarQuote) {
+ if (sql.startsWith(dollarQuote, index)) {
+ index += dollarQuote.length;
+ dollarQuote = "";
+ } else {
+ index += 1;
+ }
+ continue;
+ }
+
+ if (state === "line-comment") {
+ if (character === "\n") state = "normal";
+ index += 1;
+ continue;
+ }
+
+ if (state === "block-comment") {
+ if (character === "*" && nextCharacter === "/") {
+ state = "normal";
+ index += 2;
+ } else {
+ index += 1;
+ }
+ continue;
+ }
+
+ if (state === "single-quote") {
+ if (character === "'" && nextCharacter === "'") {
+ index += 2;
+ } else {
+ if (character === "'") state = "normal";
+ index += 1;
+ }
+ continue;
+ }
+
+ if (state === "double-quote") {
+ if (character === '"' && nextCharacter === '"') {
+ index += 2;
+ } else {
+ if (character === '"') state = "normal";
+ index += 1;
+ }
+ continue;
+ }
+
+ if (state === "backtick") {
+ if (character === "`" && nextCharacter === "`") {
+ index += 2;
+ } else {
+ if (character === "`") state = "normal";
+ index += 1;
+ }
+ continue;
+ }
+
+ if (character === "-" && nextCharacter === "-") {
+ state = "line-comment";
+ index += 2;
+ continue;
+ }
+ if (character === "/" && nextCharacter === "*") {
+ state = "block-comment";
+ index += 2;
+ continue;
+ }
+ if (character === "'") {
+ state = "single-quote";
+ index += 1;
+ continue;
+ }
+ if (character === '"') {
+ state = "double-quote";
+ index += 1;
+ continue;
+ }
+ if (character === "`") {
+ state = "backtick";
+ index += 1;
+ continue;
+ }
+ if (character === "$") {
+ const match = sql.slice(index).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/);
+ if (match) {
+ dollarQuote = match[0];
+ index += dollarQuote.length;
+ continue;
+ }
+ }
+ if (character === ";") {
+ ranges.push({ from: start, to: index + 1 });
+ start = index + 1;
+ }
+ index += 1;
+ }
+
+ ranges.push({ from: start, to: sql.length });
+
+ const clampedCursor = Math.max(0, Math.min(cursor, sql.length));
+ let selectedIndex = ranges.findIndex(
+ (range) =>
+ clampedCursor >= range.from &&
+ (clampedCursor < range.to ||
+ (clampedCursor === sql.length && range.to === sql.length)),
+ );
+ if (selectedIndex === -1) selectedIndex = ranges.length - 1;
+
+ const selected = trimRange(sql, ranges[selectedIndex]);
+ if (selected) return selected;
+
+ for (let offset = 1; offset < ranges.length; offset += 1) {
+ const previous = ranges[selectedIndex - offset];
+ if (previous) {
+ const trimmed = trimRange(sql, previous);
+ if (trimmed) return trimmed;
+ }
+
+ const next = ranges[selectedIndex + offset];
+ if (next) {
+ const trimmed = trimRange(sql, next);
+ if (trimmed) return trimmed;
+ }
+ }
+
+ return null;
+}
+
+export function trimRange(sql: string, range: TextRange): TextRange | null {
+ let { from, to } = range;
+ while (from < to && /\s/.test(sql[from])) from += 1;
+ while (to > from && /\s/.test(sql[to - 1])) to -= 1;
+ return from === to ? null : { from, to };
+}
+
+export function formatDataType(code: string | undefined): string {
+ if (!code) return "UNKNOWN";
+ const normalized = code.replace(/^CODE_/, "");
+ return normalized.startsWith("UNKNOWN(") ? "UNKNOWN" : normalized;
+}
+
+export function formatExecutionTime(milliseconds: number): string {
+ return milliseconds < 1000
+ ? `${milliseconds}ms`
+ : `${(milliseconds / 1000).toFixed(1)}s`;
+}
diff --git a/web-common/src/lib/i18n/messages/en.json b/web-common/src/lib/i18n/messages/en.json
index 5df177470ab..66b92097ba6 100644
--- a/web-common/src/lib/i18n/messages/en.json
+++ b/web-common/src/lib/i18n/messages/en.json
@@ -1289,7 +1289,7 @@
"nav_tab_alerts": "Alerts",
"nav_tab_dashboards": "Dashboards",
"nav_tab_home": "Home",
- "nav_tab_query": "Query",
+ "nav_tab_query": "SQL Console",
"nav_tab_reports": "Reports",
"nav_tab_settings": "Settings",
"nav_tab_status": "Status",
diff --git a/web-common/src/lib/i18n/messages/es.json b/web-common/src/lib/i18n/messages/es.json
index 089440a67a0..f08464df92f 100644
--- a/web-common/src/lib/i18n/messages/es.json
+++ b/web-common/src/lib/i18n/messages/es.json
@@ -1298,7 +1298,7 @@
"nav_tab_alerts": "Alertas",
"nav_tab_dashboards": "Dashboards",
"nav_tab_home": "Inicio",
- "nav_tab_query": "Consulta",
+ "nav_tab_query": "Consola SQL",
"nav_tab_reports": "Informes",
"nav_tab_settings": "Configuración",
"nav_tab_status": "Estado",