From f403773cdd9315cb996f69ddad91e227d61da328 Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 3 Aug 2026 19:52:38 +0300 Subject: [PATCH 01/17] feat: notebook-level auto-refresh interval with per-cell overrides --- e2e/questdb | 2 +- src/consts/shared-definitions.json | 50 ++- src/modules/ConsoleEventTracker/events.ts | 4 + src/providers/AIConversationProvider/types.ts | 2 + .../userActionDigest.test.ts | 20 + .../userActionDigest.ts | 4 + src/scenes/Editor/Monaco/importTabs.test.ts | 26 ++ src/scenes/Editor/Monaco/importTabs.ts | 2 + .../Editor/Notebook/NotebookProvider.tsx | 24 +- .../Notebook/NotebookRefreshControl.tsx | 133 ++++++ .../Editor/Notebook/NotebookToolbar.tsx | 8 + .../Notebook/cells/AutoRefreshOptions.tsx | 40 +- src/scenes/Editor/Notebook/cells/Cell.tsx | 15 +- .../Editor/Notebook/cells/CellDragHeader.tsx | 5 +- .../Notebook/cells/CellRefreshButton.tsx | 92 ++-- .../Editor/Notebook/cells/CellToolbar.tsx | 16 +- .../Editor/Notebook/cells/CellWideActions.tsx | 9 +- .../chartRefresh/ChartRefreshContext.tsx | 9 +- .../chartRefresh/chartRefreshEngine.test.ts | 397 +++++++++++++++++- .../chartRefresh/chartRefreshEngine.ts | 61 ++- src/scenes/Editor/Notebook/index.tsx | 12 +- .../Editor/Notebook/notebookUtils.test.ts | 89 +++- src/scenes/Editor/Notebook/notebookUtils.ts | 34 +- .../Editor/Notebook/refreshSplitButton.tsx | 57 +++ src/scenes/Editor/Notebook/useCellsStore.ts | 19 +- src/store/notebook.ts | 2 + .../ai/executeAIFlow.buildUserMessage.test.ts | 1 + .../executeAIFlow.notebookFreshness.test.ts | 1 + src/utils/ai/notebookSnapshot.test.ts | 38 ++ src/utils/ai/notebookSnapshot.ts | 5 + src/utils/ai/prompts.ts | 2 +- src/utils/ai/shared.notebookTools.test.ts | 123 ++++++ src/utils/mcp/dispatchMCPTool.test.ts | 1 + src/utils/notebooks/notebookAIBridge.ts | 7 +- .../notebooks/notebookController/index.ts | 2 + .../notebookController/notebookController.ts | 1 + .../notebookController/notebookTransitions.ts | 31 ++ src/utils/tools/applyNotebookState.ts | 39 +- src/utils/tools/dispatch.ts | 35 +- 39 files changed, 1302 insertions(+), 116 deletions(-) create mode 100644 src/scenes/Editor/Notebook/NotebookRefreshControl.tsx create mode 100644 src/scenes/Editor/Notebook/refreshSplitButton.tsx diff --git a/e2e/questdb b/e2e/questdb index 8fb0a75c1..0e12b8665 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 8fb0a75c136124ddcd24756d21fc756f8e0b408a +Subproject commit 0e12b8665922b827bbcd4099e3d59193ed4a62d8 diff --git a/src/consts/shared-definitions.json b/src/consts/shared-definitions.json index b5daabd5a..2c2383ea3 100644 --- a/src/consts/shared-definitions.json +++ b/src/consts/shared-definitions.json @@ -649,7 +649,7 @@ "surfaces": ["ai", "mcp"], "mutatesNotebook": true, "createsNotebook": false, - "description": "Set auto-refresh polling for a draw-mode cell's chart. `value`: true = adaptive poll (interval auto-tuned to response time), false = no polling, or a fixed interval string (\"1s\", \"5s\", \"10s\", \"30s\", \"1m\").", + "description": "Set auto-refresh polling for a draw-mode cell's chart, as a per-cell override of the notebook default (set_notebook_autorefresh). `value`: true = adaptive poll (interval auto-tuned to response time), false = no polling, a fixed interval string (\"1s\", \"5s\", \"10s\", \"30s\", \"1m\"), or null to clear the override so the cell inherits the notebook default.", "inputSchema": { "type": "object", "additionalProperties": false, @@ -663,7 +663,7 @@ "value": { "anyOf": [ { - "type": "boolean" + "type": ["boolean", "null"] }, { "type": "string", @@ -675,6 +675,35 @@ "required": ["buffer_id", "cell_id", "value"] } }, + { + "name": "set_notebook_autorefresh", + "category": "free", + "surfaces": ["ai", "mcp"], + "mutatesNotebook": true, + "createsNotebook": false, + "description": "Set the notebook-level auto-refresh default for draw-mode charts. Cells with no per-cell value inherit it; set_cell_autorefresh sets a per-cell override that wins over it. `value`: true = adaptive poll (interval auto-tuned to response time), false = no polling, or a fixed interval string (\"1s\", \"5s\", \"10s\", \"30s\", \"1m\").", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "buffer_id": { + "type": "number" + }, + "value": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": ["1s", "5s", "10s", "30s", "1m"] + } + ] + } + }, + "required": ["buffer_id", "value"] + } + }, { "name": "set_cell_name", "category": "free", @@ -731,7 +760,7 @@ "surfaces": ["ai", "mcp"], "mutatesNotebook": true, "createsNotebook": false, - "description": "Bulk-apply the entire desired state of a notebook in one atomic call. Use this for bulk edits spanning multiple cells or creating a notebook from scratch. Use update_cell or set_cell_* for small operations. Use INSTEAD OF chained add_cell + update_cell + set_cell_mode + set_cell_chart_config only when composing a multi-cell layout from scratch, changing many cells at once, or restructuring an existing notebook. The cells array is the COMPLETE desired list: cells in the current notebook whose id is missing from your request are DELETED. For new cells, omit `id` and one will be generated. Each cell carries exactly one of `value` (full verbatim SQL) or `preserve_value: true` (keep the existing cell's SQL, results, and run history unchanged) — prefer preserve_value for every cell whose SQL you are not changing, and NEVER send a value reconstructed from a preview or a truncated get_cell read. Charts in mode='draw' with auto_refresh=true render automatically — do not call run_cell afterwards. Cells with resolved mode='run' (explicit, or omitted: new defaults to 'run', existing preserves) auto-execute after the apply — EXCEPT cells whose statements include DDL/DML (INSERT/UPDATE/CREATE/DROP/...): those are NEVER auto-executed (their `runs` entry gets `skipped: true`), so applying state can never trigger a write's side effects. Take consent from the user, then call run_cell explicitly to execute them. Markdown cells (type:\"markdown\") are rendered prose and are likewise never auto-run. The response includes a `runs: [{cellId, success, queryCount?, results?, error?, skipped?}]` array — `results` is the per-statement status list (`\"success\"` / `\"cancelled\"` / `\"ERROR: \"`); a top-level `error` is set only when the run was refused before any statement executed. Always call get_workspace_state first; the state-freshness gate applies.", + "description": "Bulk-apply the entire desired state of a notebook in one atomic call. Use this for bulk edits spanning multiple cells or creating a notebook from scratch. Use update_cell or set_cell_* for small operations. Use INSTEAD OF chained add_cell + update_cell + set_cell_mode + set_cell_chart_config only when composing a multi-cell layout from scratch, changing many cells at once, or restructuring an existing notebook. The cells array is the COMPLETE desired list: cells in the current notebook whose id is missing from your request are DELETED. For new cells, omit `id` and one will be generated. Each cell carries exactly one of `value` (full verbatim SQL) or `preserve_value: true` (keep the existing cell's SQL, results, and run history unchanged) — prefer preserve_value for every cell whose SQL you are not changing, and NEVER send a value reconstructed from a preview or a truncated get_cell read. Charts in mode='draw' render automatically — do not call run_cell afterwards. Cells with resolved mode='run' (explicit, or omitted: new defaults to 'run', existing preserves) auto-execute after the apply — EXCEPT cells whose statements include DDL/DML (INSERT/UPDATE/CREATE/DROP/...): those are NEVER auto-executed (their `runs` entry gets `skipped: true`), so applying state can never trigger a write's side effects. Take consent from the user, then call run_cell explicitly to execute them. Markdown cells (type:\"markdown\") are rendered prose and are likewise never auto-run. The response includes a `runs: [{cellId, success, queryCount?, results?, error?, skipped?}]` array — `results` is the per-statement status list (`\"success\"` / `\"cancelled\"` / `\"ERROR: \"`); a top-level `error` is set only when the run was refused before any statement executed. Always call get_workspace_state first; the state-freshness gate applies.", "inputSchema": { "type": "object", "additionalProperties": false, @@ -744,6 +773,18 @@ "enum": ["list", "grid", null], "description": "Notebook layout mode after this apply. Null preserves current." }, + "auto_refresh_default": { + "anyOf": [ + { + "type": ["boolean", "null"] + }, + { + "type": "string", + "enum": ["1s", "5s", "10s", "30s", "1m"] + } + ], + "description": "Notebook-level auto-refresh default after this apply. Cells with no per-cell auto_refresh inherit it. Null preserves current." + }, "maximized_cell_id": { "type": ["string", "null"], "description": "Spotlight one cell id, or null to clear. Pass null to clear." @@ -809,7 +850,7 @@ "enum": ["1s", "5s", "10s", "30s", "1m"] } ], - "description": "Auto-refresh for draw cells: true = adaptive poll, false = off, or a fixed interval string (\"1s\"/\"5s\"/\"10s\"/\"30s\"/\"1m\"). Null defaults to true (adaptive) when mode='draw'." + "description": "Auto-refresh override for draw cells: true = adaptive poll, false = off, or a fixed interval string (\"1s\"/\"5s\"/\"10s\"/\"30s\"/\"1m\"). Omitted or null stores NO override — the cell inherits the notebook's auto_refresh_default." }, "is_view_maximized": { "type": ["boolean", "null"], @@ -952,6 +993,7 @@ "required": [ "buffer_id", "layout_mode", + "auto_refresh_default", "maximized_cell_id", "variables", "cells" diff --git a/src/modules/ConsoleEventTracker/events.ts b/src/modules/ConsoleEventTracker/events.ts index c0300da9a..d381dab30 100644 --- a/src/modules/ConsoleEventTracker/events.ts +++ b/src/modules/ConsoleEventTracker/events.ts @@ -118,6 +118,9 @@ export enum ConsoleEvent { NOTEBOOK_CELL_DRAW = "notebook.cell_draw", NOTEBOOK_CELL_RUN_CANCEL = "notebook.cell_run_cancel", NOTEBOOK_CELL_AUTOREFRESH_CHANGE = "notebook.cell_autorefresh_change", + NOTEBOOK_AUTOREFRESH_DEFAULT_CHANGE = "notebook.autorefresh_default_change", + NOTEBOOK_AUTOREFRESH_RESET_OVERRIDES = "notebook.autorefresh_reset_overrides", + NOTEBOOK_REFRESH_ALL = "notebook.refresh_all", NOTEBOOK_CELL_RESIZE = "notebook.cell_resize", NOTEBOOK_CELL_EXPAND_WIDTH = "notebook.cell_expand_width", NOTEBOOK_CELL_SIZE_RESET = "notebook.cell_size_reset", @@ -162,6 +165,7 @@ export enum ConsoleEvent { MCP_SET_CELL_MODE = "mcp.set_cell_mode", MCP_SET_CELL_CHART_CONFIG = "mcp.set_cell_chart_config", MCP_SET_CELL_AUTOREFRESH = "mcp.set_cell_autorefresh", + MCP_SET_NOTEBOOK_AUTOREFRESH = "mcp.set_notebook_autorefresh", MCP_SET_CELL_NAME = "mcp.set_cell_name", MCP_SET_CELL_VIEW_MAXIMIZED = "mcp.set_cell_view_maximized", MCP_SET_CELL_MAXIMIZED = "mcp.set_cell_maximized", diff --git a/src/providers/AIConversationProvider/types.ts b/src/providers/AIConversationProvider/types.ts index d6a54b039..a6592da7a 100644 --- a/src/providers/AIConversationProvider/types.ts +++ b/src/providers/AIConversationProvider/types.ts @@ -2,6 +2,7 @@ import type { PartitionBy } from "../../utils/questdb" import type { QueryKey } from "../../scenes/Editor/Monaco/utils" import type { Message } from "../../utils/ai/types" import type { RanStatus } from "../../utils/ai/runStatus" +import type { AutoRefresh } from "../../store/notebook" import type { TokenUsage } from "../../utils/ai/aiAssistant" import type { OperationHistory } from "../AIStatusProvider" @@ -68,6 +69,7 @@ export type UserActionDigest = { edited: Set ran: Map layoutModeTo?: "list" | "grid" + autoRefreshDefaultTo?: AutoRefresh notebookStatusChange?: "archived" | "deleted" } diff --git a/src/providers/AIConversationProvider/userActionDigest.test.ts b/src/providers/AIConversationProvider/userActionDigest.test.ts index 55ba3ac79..182353325 100644 --- a/src/providers/AIConversationProvider/userActionDigest.test.ts +++ b/src/providers/AIConversationProvider/userActionDigest.test.ts @@ -41,6 +41,15 @@ describe("createEmptyDigest / isEmptyDigest", () => { ]) expect(isEmptyDigest(d)).toBe(true) }) + + it("is not empty after an Off autorefresh default change", () => { + // Given only a default change to Off — false must count as a value + const d = apply([ + { kind: "user_changed_autorefresh_default", bufferId: 1, value: false }, + ]) + // Then the digest reaches the agent instead of being dropped as empty + expect(isEmptyDigest(d)).toBe(false) + }) }) describe("user_added_cell", () => { @@ -141,6 +150,17 @@ describe("user_changed_layout_mode", () => { }) }) +describe("user_changed_autorefresh_default", () => { + it("stores the final value, including Off (false)", () => { + const d = apply([ + { kind: "user_changed_autorefresh_default", bufferId: 1, value: "30s" }, + { kind: "user_changed_autorefresh_default", bufferId: 1, value: false }, + ]) + // false means "Off" — a valid final value the digest must keep + expect(d.autoRefreshDefaultTo).toBe(false) + }) +}) + describe("notebook lifecycle events", () => { it("user_archived_notebook flips the flag to archived", () => { const d = apply([{ kind: "user_archived_notebook", bufferId: 1 }]) diff --git a/src/providers/AIConversationProvider/userActionDigest.ts b/src/providers/AIConversationProvider/userActionDigest.ts index 6e275528c..31559260b 100644 --- a/src/providers/AIConversationProvider/userActionDigest.ts +++ b/src/providers/AIConversationProvider/userActionDigest.ts @@ -40,6 +40,9 @@ export const applyUserActionToDigest = ( case "user_changed_layout_mode": digest.layoutModeTo = evt.mode return digest + case "user_changed_autorefresh_default": + digest.autoRefreshDefaultTo = evt.value + return digest case "user_archived_notebook": digest.notebookStatusChange = "archived" return digest @@ -57,4 +60,5 @@ export const isEmptyDigest = (d: UserActionDigest): boolean => d.edited.size === 0 && d.ran.size === 0 && d.layoutModeTo === undefined && + d.autoRefreshDefaultTo === undefined && d.notebookStatusChange === undefined diff --git a/src/scenes/Editor/Monaco/importTabs.test.ts b/src/scenes/Editor/Monaco/importTabs.test.ts index 9be5d70f2..007ae4cd4 100644 --- a/src/scenes/Editor/Monaco/importTabs.test.ts +++ b/src/scenes/Editor/Monaco/importTabs.test.ts @@ -832,6 +832,32 @@ describe("sanitizeBuffer", () => { expect(state?.settings?.variables).toEqual([{ name: "v", value: "1" }]) }) + it("round-trips settings.autoRefreshDefault and drops an invalid token", () => { + const input = { + label: "Notebook", + value: "", + position: 0, + notebookViewState: { + cells: [{ id: "c1", value: "SELECT 1" }], + settings: { autoRefreshDefault: "30s" }, + }, + } + // A valid token survives import… + expect( + sanitizeBuffer(input).notebookViewState?.settings?.autoRefreshDefault, + ).toBe("30s") + // …Off (false) is a valid value, not an absent one… + input.notebookViewState.settings = { autoRefreshDefault: false as never } + expect( + sanitizeBuffer(input).notebookViewState?.settings?.autoRefreshDefault, + ).toBe(false) + // …and an unknown token is dropped. + input.notebookViewState.settings = { autoRefreshDefault: "2s" } + expect( + sanitizeBuffer(input).notebookViewState?.settings?.autoRefreshDefault, + ).toBeUndefined() + }) + it("keeps a fixed-interval autoRefresh token and bottomResized, drops a malformed interval", () => { const input = { label: "Notebook", diff --git a/src/scenes/Editor/Monaco/importTabs.ts b/src/scenes/Editor/Monaco/importTabs.ts index 6e3166e96..07d08c432 100644 --- a/src/scenes/Editor/Monaco/importTabs.ts +++ b/src/scenes/Editor/Monaco/importTabs.ts @@ -307,6 +307,8 @@ const sanitizeNotebookSettings = ( return typeof o.name === "string" && typeof o.value === "string" }) } + if (isAutoRefresh(item.autoRefreshDefault)) + settings.autoRefreshDefault = item.autoRefreshDefault return settings } diff --git a/src/scenes/Editor/Notebook/NotebookProvider.tsx b/src/scenes/Editor/Notebook/NotebookProvider.tsx index b60031055..062602ea7 100644 --- a/src/scenes/Editor/Notebook/NotebookProvider.tsx +++ b/src/scenes/Editor/Notebook/NotebookProvider.tsx @@ -43,11 +43,16 @@ import { } from "../../../utils/notebooks/notebookController" import { type CellRunOutcome, + clearCellAutoRefresh, computeResultBottomHeight, + countAutoRefreshOverrides, generateId, releaseCellResultPatch, snapshotResultsMatchQueries, } from "./notebookUtils" +import { signalUserEdit } from "../../../utils/notebooks/notebookAIBridge" +import { trackEvent } from "../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../modules/ConsoleEventTracker/events" import { getQueriesFromText } from "../Monaco/utils" import { silently } from "../../../utils/notebooks/notebookToolError" import type { AutoRefresh } from "../../../store/notebook" @@ -115,7 +120,9 @@ export type NotebookActions = { setCellMode: (cellId: string, mode: CellMode) => void clearCellResult: (cellId: string) => void setCellChartConfig: (cellId: string, config: ChartConfig) => void - setCellRefresh: (cellId: string, value: AutoRefresh) => void + setCellRefresh: (cellId: string, value: AutoRefresh | undefined) => void + resetAutoRefreshOverrides: () => void + refreshAllCharts: () => void setCellViewMaximized: (cellId: string, value: boolean) => void setFocusedCell: (cellId: string | null) => void setMaximizedCellId: (cellId: string | null) => void @@ -144,6 +151,8 @@ const NOOP_ACTIONS: NotebookActions = { clearCellResult: () => undefined, setCellChartConfig: () => undefined, setCellRefresh: () => undefined, + resetAutoRefreshOverrides: () => undefined, + refreshAllCharts: () => undefined, setCellViewMaximized: () => undefined, setFocusedCell: () => undefined, setMaximizedCellId: () => undefined, @@ -511,6 +520,7 @@ export const NotebookProvider: React.FC<{ const chartRefreshEngine = useChartRefreshEngine({ bufferId, cells: store.cells, + autoRefreshDefault: settings.autoRefreshDefault, deps: { executeSingle, validateWithGlobals, @@ -730,6 +740,16 @@ export const NotebookProvider: React.FC<{ [applyTransition, bufferId], ) + const resetAutoRefreshOverrides = useCallback(() => { + const count = countAutoRefreshOverrides(store.cellsRef.current) + if (count === 0) return + signalUserEdit(bufferId) + store.updateCells((prev) => prev.map(clearCellAutoRefresh)) + void trackEvent(ConsoleEvent.NOTEBOOK_AUTOREFRESH_RESET_OVERRIDES, { + count, + }) + }, [bufferId, store]) + liveActionsRef.current = { getVariables: () => settingsRef.current.variables, updateSettings, @@ -748,6 +768,8 @@ export const NotebookProvider: React.FC<{ clearCellResult, setCellChartConfig: store.setCellChartConfig, setCellRefresh: store.setCellRefresh, + resetAutoRefreshOverrides, + refreshAllCharts: () => chartRefreshEngine.refreshAll(), setCellViewMaximized, setFocusedCell, setMaximizedCellId, diff --git a/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx b/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx new file mode 100644 index 000000000..8037644ad --- /dev/null +++ b/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx @@ -0,0 +1,133 @@ +import React from "react" +import styled from "styled-components" +import { ArrowClockwiseIcon, CaretDownIcon } from "@phosphor-icons/react" +import { DropdownMenu, Tooltip } from "../../../components" +import { AutoRefreshOptions } from "./cells/AutoRefreshOptions" +import { useTriggerTooltip } from "./cells/useTriggerTooltip" +import { + useNotebookActions, + useNotebookBufferId, + useNotebookState, +} from "./NotebookProvider" +import { autoRefreshLabel, countAutoRefreshOverrides } from "./notebookUtils" +import type { AutoRefresh } from "../../../store/notebook" +import { + IntervalLabel, + OverrideDot, + SplitButtonContainer, + SplitDivider, + SplitSide, +} from "./refreshSplitButton" +import { + emitUserAction, + signalUserEdit, +} from "../../../utils/notebooks/notebookAIBridge" +import { trackEvent } from "../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../modules/ConsoleEventTracker/events" + +const ResetItemTitle = styled.span` + display: inline-flex; + align-items: center; + gap: 0.6rem; +` + +export const NotebookRefreshControl: React.FC = () => { + const { cells, settings } = useNotebookState() + const { refreshAllCharts, resetAutoRefreshOverrides, updateSettings } = + useNotebookActions() + const bufferId = useNotebookBufferId() + const defaultValue = settings.autoRefreshDefault ?? true + const defaultLabel = autoRefreshLabel(defaultValue) + const overrideCount = countAutoRefreshOverrides(cells) + const drawCellCount = cells.filter((cell) => cell.mode === "draw").length + const intervalAriaLabel = + overrideCount > 0 + ? `Notebook auto-refresh: ${defaultLabel}, ${overrideCount} ${ + overrideCount === 1 ? "cell override" : "cell overrides" + }` + : `Notebook auto-refresh: ${defaultLabel}` + const intervalTooltip = useTriggerTooltip() + + const handleRefreshAll = () => { + void trackEvent(ConsoleEvent.NOTEBOOK_REFRESH_ALL, { + chartCount: drawCellCount, + }) + signalUserEdit(bufferId) + refreshAllCharts() + } + + const handleSelectDefault = (value: AutoRefresh | undefined) => { + if (value === undefined || value === defaultValue) return + void trackEvent(ConsoleEvent.NOTEBOOK_AUTOREFRESH_DEFAULT_CHANGE, { + from: defaultLabel, + to: autoRefreshLabel(value), + }) + updateSettings({ autoRefreshDefault: value }) + emitUserAction({ + kind: "user_changed_autorefresh_default", + bufferId, + value, + }) + } + + return ( + + + + + + + + + + + + {overrideCount > 0 && } + {defaultLabel} + + + + + + + + {overrideCount > 0 && ( + <> + + + + + Reset cell overrides + + + + )} + + + + + ) +} diff --git a/src/scenes/Editor/Notebook/NotebookToolbar.tsx b/src/scenes/Editor/Notebook/NotebookToolbar.tsx index fd29448ad..81f6f91d4 100644 --- a/src/scenes/Editor/Notebook/NotebookToolbar.tsx +++ b/src/scenes/Editor/Notebook/NotebookToolbar.tsx @@ -27,6 +27,7 @@ import { } from "../../../providers/AIStatusProvider" import { emitUserAction } from "../../../utils/notebooks/notebookAIBridge" import { VariablesPopover } from "./globals/VariablesPopover" +import { NotebookRefreshControl } from "./NotebookRefreshControl" const Toolbar = styled(Box).attrs({ align: "center", @@ -40,6 +41,12 @@ const Toolbar = styled(Box).attrs({ flex-shrink: 0; position: relative; z-index: 1; + overflow-x: auto; + overflow-y: hidden; + + & > * { + flex-shrink: 0; + } ` const NotebookGlyph = styled(NotebookIcon)` @@ -357,6 +364,7 @@ export const NotebookToolbar: React.FC = () => { + String(option) -const fromKey = (key: string): AutoRefresh => - AUTO_REFRESH_OPTIONS.find((option) => optionKey(option) === key) ?? true +const INHERIT_KEY = "inherit" + +const fromKey = (key: string): AutoRefresh | undefined => + key === INHERIT_KEY + ? undefined + : (AUTO_REFRESH_OPTIONS.find((option) => optionKey(option) === key) ?? true) + +const OptionsGroup = styled(DropdownMenu.RadioGroup)` + min-width: 20rem; +` type Props = { - value: AutoRefresh - onSelect: (value: AutoRefresh) => void + value: AutoRefresh | undefined + onSelect: (value: AutoRefresh | undefined) => void + inheritedValue?: AutoRefresh } -export const AutoRefreshOptions: React.FC = ({ value, onSelect }) => ( - = ({ + value, + onSelect, + inheritedValue, +}) => ( + onSelect(fromKey(key))} > + {inheritedValue !== undefined && ( + <> + + {value !== undefined && } + {`Notebook default (${autoRefreshLabel(inheritedValue)})`} + + + + )} {AUTO_REFRESH_OPTIONS.map((option) => ( {autoRefreshLabel(option)} ))} - + ) diff --git a/src/scenes/Editor/Notebook/cells/Cell.tsx b/src/scenes/Editor/Notebook/cells/Cell.tsx index 8e6557cbf..6be5a3dc5 100644 --- a/src/scenes/Editor/Notebook/cells/Cell.tsx +++ b/src/scenes/Editor/Notebook/cells/Cell.tsx @@ -17,7 +17,7 @@ import { useCellWrapperInteractions } from "./useCellWrapperInteractions" import { ResizeHandle } from "../resize" import { CellWrapper } from "./CellWrapper" import type { ChartConfig } from "../CellChart/chartTypes" -import type { NotebookCell } from "../../../../store/notebook" +import type { AutoRefresh, NotebookCell } from "../../../../store/notebook" import { exceedsCellLineLimit } from "../../../../store/notebook" import { useCellSelectionDecoration } from "./useCellSelectionDecoration" import { useMonacoCellEditor } from "./useMonacoCellEditor" @@ -33,6 +33,7 @@ import { isDoubleView, isExpectingResult, MIN_BOTTOM_HEIGHT_PX, + resolveAutoRefresh, resolveCellView, } from "../notebookUtils" import { @@ -112,6 +113,7 @@ type Props = { index: number totalCells: number layoutMode?: "list" | "grid" + autoRefreshDefault: AutoRefresh | undefined isFocused: boolean isMaximized: boolean isRunning: boolean @@ -122,6 +124,7 @@ const CellInner: React.FC = ({ index, totalCells, layoutMode = "list", + autoRefreshDefault, isFocused, isMaximized, isRunning, @@ -181,6 +184,10 @@ const CellInner: React.FC = ({ const runActive = !isDrawMode && doubleView const view = resolveCellView(cell) const canRun = !!stripSQLComments(cell.value).trim() + const effectiveAutoRefresh = resolveAutoRefresh( + cell.autoRefresh, + autoRefreshDefault, + ) const { topHeight, @@ -412,6 +419,7 @@ const CellInner: React.FC = ({ cellIndex={index} totalCells={totalCells} layoutMode={layoutMode} + autoRefreshDefault={autoRefreshDefault} isMaximized={isMaximized} isRunning={isRunning} headerRef={headerRef} @@ -439,7 +447,7 @@ const CellInner: React.FC = ({ runActive={runActive} isDrawMode={isDrawMode} canRun={canRun} - autoRefreshOn={cell.autoRefresh !== false} + autoRefreshOn={effectiveAutoRefresh !== false} showLabels={toolbarTier === "expanded"} onRun={runAll} onHideResult={() => { @@ -457,7 +465,8 @@ const CellInner: React.FC = ({ = ({ cellIndex, totalCells, layoutMode, + autoRefreshDefault, isMaximized, isRunning = false, left, @@ -109,6 +111,7 @@ export const CellDragHeader: React.FC = ({ cellIndex={cellIndex} totalCells={totalCells} layoutMode={layoutMode} + autoRefreshDefault={autoRefreshDefault} isMaximized={isMaximized} isRunning={isRunning} inline diff --git a/src/scenes/Editor/Notebook/cells/CellRefreshButton.tsx b/src/scenes/Editor/Notebook/cells/CellRefreshButton.tsx index 70ce7ef03..d92129e02 100644 --- a/src/scenes/Editor/Notebook/cells/CellRefreshButton.tsx +++ b/src/scenes/Editor/Notebook/cells/CellRefreshButton.tsx @@ -1,83 +1,47 @@ import React from "react" -import styled from "styled-components" import { ArrowClockwiseIcon, CaretDownIcon } from "@phosphor-icons/react" -import { DropdownMenu, Tooltip, Button } from "../../../../components" +import { DropdownMenu, Tooltip } from "../../../../components" import { Spinner } from "./Spinner" import { AutoRefreshOptions } from "./AutoRefreshOptions" import { useTriggerTooltip } from "./useTriggerTooltip" import { useNotebookActions, useNotebookBufferId } from "../NotebookProvider" -import { autoRefreshLabel } from "../notebookUtils" +import { autoRefreshLabel, resolveAutoRefresh } from "../notebookUtils" import type { AutoRefresh } from "../../../../store/notebook" +import { + IntervalLabel, + OverrideDot, + SplitButtonContainer, + SplitDivider, + SplitSide, +} from "../refreshSplitButton" import { signalUserEdit } from "../../../../utils/notebooks/notebookAIBridge" import { eventBus } from "../../../../modules/EventBus" import { EventType } from "../../../../modules/EventBus/types" import { trackEvent } from "../../../../modules/ConsoleEventTracker" import { ConsoleEvent } from "../../../../modules/ConsoleEventTracker/events" -const Container = styled.div` - display: flex; - align-items: center; - border-radius: 0.4rem; - background: ${({ theme }) => theme.color.backgroundLighter}; - border: 1px solid ${({ theme }) => `${theme.color.selection}80`}; -` - -const SplitSide = styled(Button)` - display: flex; - align-items: center; - gap: 0.5rem; - height: 3rem; - padding: 0 1.1rem; - border: none; - border-radius: 0; - color: ${({ theme }) => theme.color.foreground}; - font-size: 1.4rem; - cursor: pointer; - - svg { - width: 1.8rem; - height: 1.8rem; - } - - &&:hover:not(:disabled) { - background: ${({ theme }) => `${theme.color.selection}80`}; - color: ${({ theme }) => theme.color.foreground}; - } - - &:disabled { - opacity: 0.5; - } -` - -const SplitDivider = styled.div` - width: 1px; - align-self: stretch; - margin: 0; - background: ${({ theme }) => theme.color.selection}; -` - -const IntervalLabel = styled.span` - color: ${({ theme }) => theme.color.gray2}; -` - type Props = { cellId: string // Grid refreshes by re-running the query and has no auto-refresh interval; // chart refreshes its own fetch and exposes the interval dropdown. view: "grid" | "chart" - autoRefresh: AutoRefresh + cellAutoRefresh: AutoRefresh | undefined + autoRefreshDefault: AutoRefresh | undefined isRefreshing: boolean } export const CellRefreshButton: React.FC = ({ cellId, view, - autoRefresh, + cellAutoRefresh, + autoRefreshDefault, isRefreshing, }) => { const { setCellRefresh } = useNotebookActions() const bufferId = useNotebookBufferId() const isChart = view === "chart" + const autoRefresh = resolveAutoRefresh(cellAutoRefresh, autoRefreshDefault) + const hasOverride = cellAutoRefresh !== undefined const intervalTooltip = useTriggerTooltip() const handleRefresh = (e: React.MouseEvent) => { @@ -91,10 +55,11 @@ export const CellRefreshButton: React.FC = ({ { cellId }, ) } - const handleSelect = (value: AutoRefresh) => { + const handleSelect = (value: AutoRefresh | undefined) => { + if (value === cellAutoRefresh) return void trackEvent(ConsoleEvent.NOTEBOOK_CELL_AUTOREFRESH_CHANGE, { from: autoRefreshLabel(autoRefresh), - to: autoRefreshLabel(value), + to: value === undefined ? "default" : autoRefreshLabel(value), trigger: "button", }) signalUserEdit(bufferId) @@ -102,7 +67,7 @@ export const CellRefreshButton: React.FC = ({ } return ( - + = ({ @@ -128,8 +97,11 @@ export const CellRefreshButton: React.FC = ({ skin="transparent" type="button" onClick={(e) => e.stopPropagation()} - aria-label="Auto-refresh interval" + aria-label={`Auto-refresh interval: ${autoRefreshLabel( + autoRefresh, + )}${hasOverride ? " (overrides notebook default)" : ""}`} > + {hasOverride && } {autoRefreshLabel(autoRefresh)} @@ -138,14 +110,18 @@ export const CellRefreshButton: React.FC = ({ )} - + ) } diff --git a/src/scenes/Editor/Notebook/cells/CellToolbar.tsx b/src/scenes/Editor/Notebook/cells/CellToolbar.tsx index 79a0f2956..75b89ae78 100644 --- a/src/scenes/Editor/Notebook/cells/CellToolbar.tsx +++ b/src/scenes/Editor/Notebook/cells/CellToolbar.tsx @@ -26,6 +26,7 @@ import { useTriggerTooltip } from "./useTriggerTooltip" import { autoRefreshLabel, cellToolbarMenuFlags, + resolveAutoRefresh, resolveCellView, } from "../notebookUtils" import type { CellToolbarTier } from "../notebookUtils" @@ -85,6 +86,7 @@ type Props = { cellIndex: number totalCells: number layoutMode: "list" | "grid" + autoRefreshDefault?: AutoRefresh isMaximized: boolean isRunning?: boolean inline?: boolean @@ -98,6 +100,7 @@ export const CellToolbar: React.FC = ({ cellIndex, totalCells, layoutMode, + autoRefreshDefault, isMaximized, isRunning = false, inline, @@ -127,7 +130,7 @@ export const CellToolbar: React.FC = ({ const isGridView = view === "grid" const isNoneView = view === "none" const isViewMaximized = !isNoneView && !!cell.isViewMaximized - const autoRefresh = cell.autoRefresh ?? true + const autoRefresh = resolveAutoRefresh(cell.autoRefresh, autoRefreshDefault) const [menuOpen, setMenuOpen] = useState(false) const moreActionsTooltip = useTriggerTooltip() @@ -233,10 +236,11 @@ export const CellToolbar: React.FC = ({ }) eventBus.publish(EventType.NOTEBOOK_CELL_OPEN_CHART_SETTINGS, { cellId }) } - const handleRefreshSelect = (value: AutoRefresh) => { + const handleRefreshSelect = (value: AutoRefresh | undefined) => { + if (value === cell.autoRefresh) return void trackEvent(ConsoleEvent.NOTEBOOK_CELL_AUTOREFRESH_CHANGE, { from: autoRefreshLabel(autoRefresh), - to: autoRefreshLabel(value), + to: value === undefined ? "default" : autoRefreshLabel(value), trigger: "menu", }) signalUserEdit(bufferId) @@ -377,8 +381,12 @@ export const CellToolbar: React.FC = ({ diff --git a/src/scenes/Editor/Notebook/cells/CellWideActions.tsx b/src/scenes/Editor/Notebook/cells/CellWideActions.tsx index 306c14944..c7df6a877 100644 --- a/src/scenes/Editor/Notebook/cells/CellWideActions.tsx +++ b/src/scenes/Editor/Notebook/cells/CellWideActions.tsx @@ -8,7 +8,8 @@ type Props = { // Only the grid/chart views reach here — the neutral (none) state renders the // Run/Draw toggles instead. view: "grid" | "chart" - autoRefresh: AutoRefresh + cellAutoRefresh: AutoRefresh | undefined + autoRefreshDefault: AutoRefresh | undefined isViewMaximized: boolean isRunning: boolean isGridLoading: boolean @@ -20,7 +21,8 @@ type Props = { export const CellWideActions: React.FC = ({ cellId, view, - autoRefresh, + cellAutoRefresh, + autoRefreshDefault, isViewMaximized, isRunning, isGridLoading, @@ -35,7 +37,8 @@ export const CellWideActions: React.FC = ({ useContext(ChartRefreshContext) export const useChartRefreshEngine = (options: { bufferId: number cells: NotebookCell[] + autoRefreshDefault?: AutoRefresh deps: ChartRefreshDeps }): ChartRefreshEngine => { - const { bufferId, cells, deps } = options + const { bufferId, cells, autoRefreshDefault, deps } = options const depsRef = useRef(deps) const engine = useMemo( () => new ChartRefreshEngine(bufferId, () => depsRef.current), @@ -41,8 +42,8 @@ export const useChartRefreshEngine = (options: { }, [engine]) useEffect(() => { - engine.sync(cells) - }, [engine, cells]) + engine.sync(cells, autoRefreshDefault) + }, [engine, cells, autoRefreshDefault]) return engine } diff --git a/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.test.ts b/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.test.ts index 7c94fc415..f7717ce07 100644 --- a/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.test.ts +++ b/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.test.ts @@ -157,9 +157,12 @@ describe("ChartRefreshEngine", () => { // Entries start hidden until an observer reports them (no init-load fetch // burst); tests that model on-screen cells report visibility before sync. - const syncOnScreen = (cells: NotebookCell[]) => { + const syncOnScreen = ( + cells: NotebookCell[], + autoRefreshDefault?: AutoRefresh, + ) => { for (const cell of cells) engine.setVisible(cell.id, true) - engine.sync(cells) + engine.sync(cells, autoRefreshDefault) } beforeEach(() => { @@ -1500,4 +1503,394 @@ describe("ChartRefreshEngine", () => { error: "network down", }) }) + + describe("notebook auto-refresh default", () => { + const inheriting = (id: string, value: string): NotebookCell => ({ + id, + position: 0, + value, + mode: "draw", + }) + + it("polls an inheriting cell at the notebook default while an override keeps its own cadence", async () => { + // Given an inheriting cell and a 5s-override cell under a 1s default + syncOnScreen( + [inheriting("c1", "select 1"), drawCell("c2", "select 2", "5s")], + "1s", + ) + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + + // When one default interval elapses + await vi.advanceTimersByTimeAsync(1000) + + // Then only the inheriting cell fetched again + expect( + deps.executeSingle.mock.calls.slice(2).map(([sql]) => sql), + ).toEqual(["select 1"]) + + // And the override cell fetches on its own 5s cadence + await vi.advanceTimersByTimeAsync(4000) + expect( + deps.executeSingle.mock.calls.slice(2).map(([sql]) => sql), + ).toContain("select 2") + }) + + it("an Off default stops inheriting cells after one settle-fetch", async () => { + // Given an inheriting cell under an Off default + syncOnScreen([inheriting("c1", "select 1")], false) + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + + // Then no polling ever starts + await vi.advanceTimersByTimeAsync(120_000) + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + }) + + it("a default-only change bypasses sameDrawCells and restarts the sleeping poll on the new cadence", async () => { + // Given an inheriting cell synced under a 1s default + const cells = [inheriting("c1", "select 1")] + syncOnScreen(cells, "1s") + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + + // When the SAME cell array re-syncs with a 5s default + engine.sync(cells, "5s") + + // Then the next fetch waits the new interval, not the old one + await vi.advanceTimersByTimeAsync(1000) + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(4000) + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + }) + }) + + describe("refreshAll", () => { + it("fetches only visible entries on the click; hidden entries send nothing", async () => { + // Given one visible and one hidden settled Off cell + engine.setVisible("c1", true) + engine.setVisible("c2", false) + engine.sync([ + drawCell("c1", "select 1", false), + drawCell("c2", "select 2", false), + ]) + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + + // When the user clicks refresh-all + engine.refreshAll() + await flushAsync() + + // Then only the visible cell refetched + expect(deps.executeSingle.mock.calls.map(([sql]) => sql)).toEqual([ + "select 1", + "select 1", + ]) + + // And no request ever fires for the hidden cell off-screen + await vi.advanceTimersByTimeAsync(60_000) + expect(deps.executeSingle.mock.calls.map(([sql]) => sql)).not.toContain( + "select 2", + ) + }) + + it("queues visible refetches through the fetch limiter", async () => { + // Given an engine capped at one in-flight fetch with two settled cells + engine.destroy() + engine = new ChartRefreshEngine( + BUFFER_ID, + () => deps as ChartRefreshDeps, + { initialFetchJitterMs: 0, maxConcurrentFetches: 1 }, + ) + engine.attach() + syncOnScreen([ + drawCell("c1", "select 1", false), + drawCell("c2", "select 2", false), + ]) + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + + // When refresh-all fires and the first refetch is slow + let release!: () => void + deps.executeSingle + .mockImplementationOnce( + (sql: string) => + new Promise((res) => { + release = () => res(dqlResult(sql)) + }), + ) + .mockImplementation((sql: string) => Promise.resolve(dqlResult(sql))) + engine.refreshAll() + await flushAsync() + + // Then only one refetch is in flight; the second waits its slot + expect(deps.executeSingle).toHaveBeenCalledTimes(3) + release() + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(4) + }) + + it("a hidden polling cell redeems its pending refresh with exactly one fetch on reveal", async () => { + // Given a polling cell that fetched while visible and then hid + syncOnScreen([drawCell("c1", "select 1", "1s")]) + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + engine.setVisible("c1", false) + + // When refresh-all flags it — nothing fetches while hidden + engine.refreshAll() + await vi.advanceTimersByTimeAsync(5000) + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + + // And the cell is revealed while its data would still count as fresh + engine.setVisible("c1", true) + await flushAsync() + + // Then exactly one catch-up fetch runs (without the flag, the fresh + // data would skip it) and the normal poll cadence resumes + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1000) + expect(deps.executeSingle).toHaveBeenCalledTimes(3) + }) + + it("a hidden Off cell redeems its pending refresh on reveal instead of settling silently", async () => { + // Given a settled Off cell that hid + syncOnScreen([drawCell("c1", "select 1", false)]) + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + engine.setVisible("c1", false) + engine.refreshAll() + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + + // When the cell is revealed + engine.setVisible("c1", true) + await flushAsync() + + // Then the flag forces one real fetch — ensureData alone would settle + // from the existing frame without touching the network + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + + // And no poll starts for an Off cell afterwards + await vi.advanceTimersByTimeAsync(60_000) + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + }) + + it("requestHydrate does not consume the pending refresh; the later reveal does", async () => { + // Given a hidden flagged cell whose snapshot loads in the retain band + harness.beginLoadOnRequest() + engine.sync([drawCell("c1", "select 1", false)]) + engine.refreshAll() + engine.requestHydrate("c1") + await flushAsync() + harness.settleLoad("c1", dqlCellResult("select 1")) + await flushAsync() + + // Then hydration fetched nothing + expect(deps.executeSingle).not.toHaveBeenCalled() + + // When the cell is really revealed + engine.setVisible("c1", true) + await flushAsync() + + // Then the pending refresh fires exactly once + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + }) + + it("no double fetch on reveal with jitter and a slow query — polling branch", async () => { + // Given a jittered engine (deterministic 150ms) with a flagged hidden + // polling cell + const random = vi.spyOn(Math, "random").mockReturnValue(0.5) + engine.destroy() + engine = new ChartRefreshEngine( + BUFFER_ID, + () => deps as ChartRefreshDeps, + { initialFetchJitterMs: 300 }, + ) + engine.attach() + engine.setVisible("c1", true) + engine.sync([drawCell("c1", "select 1", "1s")]) + await vi.advanceTimersByTimeAsync(150) + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + engine.setVisible("c1", false) + engine.refreshAll() + + // When the reveal restarts the poll and its first fetch is SLOW — + // the race window the forced-fetch design would have hit + let release!: () => void + deps.executeSingle.mockImplementation( + (sql: string) => + new Promise((res) => { + release = () => res(dqlResult(sql)) + }), + ) + engine.setVisible("c1", true) + await vi.advanceTimersByTimeAsync(150) + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + + // Then no second fetch piles on while the slow one is in flight + await vi.advanceTimersByTimeAsync(2000) + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + release() + await flushAsync() + random.mockRestore() + }) + + it("the pending refresh dies when the cell leaves draw mode", async () => { + // Given a settled Off cell that hid and got flagged + syncOnScreen([drawCell("c1", "select 1", false)]) + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + engine.setVisible("c1", false) + engine.refreshAll() + + // When the cell exits draw mode and returns while still hidden + engine.sync([{ ...drawCell("c1", "select 1", false), mode: "run" }]) + engine.sync([drawCell("c1", "select 1", false)]) + await flushAsync() + + // Then the reveal settles from the existing frame — no redeemed fetch + engine.setVisible("c1", true) + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + }) + + it("refresh-all in a hidden tab flags every cell and redeems them on return", async () => { + // Given two visible settled Off cells and a hidden tab + syncOnScreen([ + drawCell("c1", "select 1", false), + drawCell("c2", "select 2", false), + ]) + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + setDocumentHidden(true) + + // When refresh-all fires — a hidden tab must not fetch + engine.refreshAll() + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + + // Then the tab's return redeems both flags + setDocumentHidden(false) + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(4) + }) + + it("a repeated refresh-all click neither aborts nor duplicates the in-flight fetch", async () => { + // Given a visible Off cell whose refetch is slow + syncOnScreen([drawCell("c1", "select 1", false)]) + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + let release!: () => void + deps.executeSingle.mockImplementation( + (sql: string) => + new Promise((res) => { + release = () => res(dqlResult(sql)) + }), + ) + + // When the user clicks twice in a row + engine.refreshAll() + await flushAsync() + engine.refreshAll() + await flushAsync() + + // Then exactly one refetch runs, and it lands + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + release() + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + expect(cellResults.get("c1")).toBeDefined() + }) + + it("a flagged cell cleared to empty SQL clears its data on reveal instead of fetching", async () => { + // Given a settled cell that hid, got flagged, and lost its SQL + syncOnScreen([drawCell("c1", "select 1", false)]) + await flushAsync() + expect(cellResults.get("c1")).toBeDefined() + engine.setVisible("c1", false) + engine.refreshAll() + engine.sync([drawCell("c1", "", false)]) + await vi.advanceTimersByTimeAsync(301) + + // When the cell is revealed + engine.setVisible("c1", true) + await flushAsync() + + // Then no query runs and the stale rows are gone + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + expect(cellResults.get("c1")).toBeUndefined() + }) + + it("a flagged cell edited while hidden fetches the NEW SQL once on reveal", async () => { + // Given a settled cell that hid, got flagged, and was edited + syncOnScreen([drawCell("c1", "select 1", false)]) + await flushAsync() + engine.setVisible("c1", false) + engine.refreshAll() + engine.sync([drawCell("c1", "select 2", false)]) + await vi.advanceTimersByTimeAsync(301) + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + + // When the cell is revealed + engine.setVisible("c1", true) + await flushAsync() + + // Then the redeemed fetch runs the new SQL exactly once + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + expect(deps.executeSingle).toHaveBeenLastCalledWith( + "select 2", + expect.any(AbortSignal), + 10_000, + ) + }) + + it("refresh-all on a visible polling cell fetches immediately and restarts the cadence", async () => { + // Given a visible 30s cell that last fetched 10s ago + syncOnScreen([drawCell("c1", "select 1", "30s")]) + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(10_000) + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + + // When the user clicks refresh-all + engine.refreshAll() + await flushAsync() + + // Then one fetch fires immediately despite the fresh data + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + + // And the old tick time passes without a fetch — the cadence restarted + await vi.advanceTimersByTimeAsync(20_000) + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + + // And the next poll fires one full interval after the forced fetch + await vi.advanceTimersByTimeAsync(10_000) + expect(deps.executeSingle).toHaveBeenCalledTimes(3) + }) + + it("refresh-all promotes an edit still inside the debounce and fetches only the new SQL", async () => { + // Given a settled Off cell whose SQL was just edited + syncOnScreen([drawCell("c1", "select 1", false)]) + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + engine.sync([drawCell("c1", "select 2", false)]) + + // When the user clicks refresh-all before the debounce expires + engine.refreshAll() + await flushAsync() + + // Then exactly one fetch runs, with the edited SQL + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + expect(deps.executeSingle).toHaveBeenLastCalledWith( + "select 2", + expect.any(AbortSignal), + 10_000, + ) + + // And the debounce expiry does not fetch a second time + await vi.advanceTimersByTimeAsync(1000) + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + }) + }) }) diff --git a/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.ts b/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.ts index ab6fd7213..3d062e5b2 100644 --- a/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.ts +++ b/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.ts @@ -15,6 +15,7 @@ import { getQueriesFromText, normalizeQueryText } from "../../Monaco/utils" import { autoRefreshIntervalMs, NOTEBOOK_ROW_CAP, + resolveAutoRefresh, singleResultFromExec, sqlHash, } from "../notebookUtils" @@ -149,6 +150,7 @@ type Entry = { sql: string autoRefresh: AutoRefresh visible: boolean + pendingManualRefresh: boolean ensureAttempted: boolean lastFetchedAt: number state: ChartFetchState @@ -171,6 +173,7 @@ export class ChartRefreshEngine { private listeners = new PerKeyListeners() private visibilityByCell = new Map() private lastSyncedDrawCells: DrawCellSyncKey[] | null = null + private autoRefreshDefault: AutoRefresh | undefined private documentHidden = false private limitFetch: (task: () => Promise) => Promise private initialFetchJitterMs: number @@ -233,9 +236,12 @@ export class ChartRefreshEngine { this.lastSyncedDrawCells = null } - sync(cells: NotebookCell[]) { + sync(cells: NotebookCell[], autoRefreshDefault?: AutoRefresh) { const drawCells = cells.filter((cell) => cell.mode === "draw") + const defaultChanged = autoRefreshDefault !== this.autoRefreshDefault + this.autoRefreshDefault = autoRefreshDefault if ( + !defaultChanged && this.lastSyncedDrawCells && sameDrawCells(this.lastSyncedDrawCells, drawCells) ) @@ -274,6 +280,44 @@ export class ChartRefreshEngine { if (entry) void this.fetchOnce(entry) } + refreshAll() { + for (const entry of this.entries.values()) { + if (entry.inFlight) continue + if (entry.visible && !this.documentHidden) this.forceRefresh(entry) + else entry.pendingManualRefresh = true + } + } + + private forceRefresh(entry: Entry) { + if (!entry.visible || this.documentHidden) { + entry.pendingManualRefresh = true + return + } + entry.pendingManualRefresh = false + if (this.promotePendingSql(entry)) return + if (this.shouldPoll(entry)) { + entry.lastFetchedAt = 0 + entry.poll?.abort() + entry.poll = null + entry.pollKey = null + this.updatePoll(entry) + } else { + void this.fetchOnce(entry) + } + } + + private promotePendingSql(entry: Entry): boolean { + if (entry.sqlDebounce) { + clearTimeout(entry.sqlDebounce) + entry.sqlDebounce = null + } + const sql = entry.pendingSql + entry.pendingSql = null + if (sql == null || sql === entry.sql) return false + this.applySql(entry, sql) + return true + } + // Called by the notebook's cell visibility observer. Hiding pauses the poll // (in-flight fetches finish and land); revealing resumes it, fetching // immediately when the data is older than the cell's interval. @@ -301,6 +345,10 @@ export class ChartRefreshEngine { } private resume(entry: Entry) { + if (entry.pendingManualRefresh) { + this.forceRefresh(entry) + return + } this.ensureData(entry) } @@ -316,9 +364,13 @@ export class ChartRefreshEngine { const entry: Entry = { cellId: cell.id, sql: cell.value, - autoRefresh: cell.autoRefresh ?? true, + autoRefresh: resolveAutoRefresh( + cell.autoRefresh, + this.autoRefreshDefault, + ), state: pendingChartFetchState(cell.value), visible: this.visibilityByCell.get(cell.id) ?? false, + pendingManualRefresh: false, ensureAttempted: false, lastFetchedAt: 0, classifyCache: new Map(), @@ -339,7 +391,10 @@ export class ChartRefreshEngine { } private updateEntry(entry: Entry, cell: NotebookCell) { - const autoRefresh = cell.autoRefresh ?? true + const autoRefresh = resolveAutoRefresh( + cell.autoRefresh, + this.autoRefreshDefault, + ) if (autoRefresh !== entry.autoRefresh) { entry.autoRefresh = autoRefresh this.updatePoll(entry) diff --git a/src/scenes/Editor/Notebook/index.tsx b/src/scenes/Editor/Notebook/index.tsx index 7a3fbf532..4b27789e4 100644 --- a/src/scenes/Editor/Notebook/index.tsx +++ b/src/scenes/Editor/Notebook/index.tsx @@ -28,7 +28,7 @@ import { } from "./NotebookProvider" import { Cell } from "./cells/Cell" import { MarkdownCell } from "./cells/MarkdownCell" -import type { NotebookCell } from "../../../store/notebook" +import type { AutoRefresh, NotebookCell } from "../../../store/notebook" import { AddCellBottom, AddCellBetween } from "./cells/AddCellButton" import { Button, LoadingSpinner } from "../../../components" import { NotebookToolbar } from "./NotebookToolbar" @@ -326,20 +326,21 @@ type CellViewProps = { index: number totalCells: number layoutMode: "list" | "grid" + autoRefreshDefault: AutoRefresh | undefined isFocused: boolean isMaximized: boolean isRunning: boolean } -const CellView: React.FC = (props) => +const CellView: React.FC = ({ autoRefreshDefault, ...props }) => props.cell.type === "markdown" ? ( ) : ( - + ) const ListLayout: React.FC = () => { - const { cells, focusedCellId, maximizedCellId, runningCellIds } = + const { cells, settings, focusedCellId, maximizedCellId, runningCellIds } = useNotebookState() const { setFocusedCell } = useNotebookActions() useScrollUserAddedCellIntoView() @@ -361,6 +362,7 @@ const ListLayout: React.FC = () => { index={index} totalCells={cells.length} layoutMode="list" + autoRefreshDefault={settings.autoRefreshDefault} isFocused={focusedCellId === cell.id} isMaximized={maximizedCellId === cell.id} isRunning={runningCellIds.has(cell.id)} @@ -648,6 +650,7 @@ const GridLayout: React.FC = () => { index={index} totalCells={cells.length} layoutMode="grid" + autoRefreshDefault={settings.autoRefreshDefault} isFocused={focusedCellId === cell.id} isMaximized={maximizedCellId === cell.id} isRunning={runningCellIds.has(cell.id)} @@ -804,6 +807,7 @@ const NotebookContent: React.FC = () => { index={cells.indexOf(maximizedCell)} totalCells={cells.length} layoutMode={layoutMode} + autoRefreshDefault={settings.autoRefreshDefault} isFocused={focusedCellId === cell.id} isMaximized isRunning={runningCellIds.has(cell.id)} diff --git a/src/scenes/Editor/Notebook/notebookUtils.test.ts b/src/scenes/Editor/Notebook/notebookUtils.test.ts index 8f4551e9b..d5026ccf4 100644 --- a/src/scenes/Editor/Notebook/notebookUtils.test.ts +++ b/src/scenes/Editor/Notebook/notebookUtils.test.ts @@ -15,9 +15,14 @@ import { buildPersistPayload, capResultBytes, cellHeightPatchForRows, + cellModeChangePatch, cellToolbarMenuFlags, cellToolbarTier, + clearCellAutoRefresh, cloneNotebookViewState, + countAutoRefreshOverrides, + isAutoRefreshOverride, + resolveAutoRefresh, cloneNotebookViewStateWithCellIdMap, computeAgentCellGridH, computeCellGridH, @@ -1172,7 +1177,7 @@ describe("buildAppliedCells", () => { expect(nextCells[0].chartConfig?.queries).toHaveLength(2) }) - it("defaults isViewMaximized and autoRefresh to true on new draw cells", () => { + it("defaults isViewMaximized to true and stores no autoRefresh key on new draw cells", () => { const { nextCells } = buildAppliedCells([], { cells: [ { @@ -1185,7 +1190,8 @@ describe("buildAppliedCells", () => { }, ], }) - expect(nextCells[0].autoRefresh).toBe(true) + // No stored key: the cell inherits the notebook default. + expect("autoRefresh" in nextCells[0]).toBe(false) expect(nextCells[0].isViewMaximized).toBe(true) }) @@ -1273,7 +1279,7 @@ describe("buildAppliedCells", () => { expect(nextCells[0].mode).toBe("draw") }) - it("applies a fixed refresh interval to a draw cell and defaults to adaptive when omitted", () => { + it("applies a fixed refresh interval to a draw cell and clears it to inherit when omitted", () => { // Given a draw cell created with a 5s fixed interval const drawCell = { value: "SELECT 1", @@ -1292,8 +1298,8 @@ describe("buildAppliedCells", () => { const { nextCells } = buildAppliedCells(created, { cells: [{ ...drawCell, id: created[0].id }], }) - // Then a draw cell defaults back to adaptive (true) - expect(nextCells[0].autoRefresh).toBe(true) + // Then no key remains — the cell inherits the notebook default + expect("autoRefresh" in nextCells[0]).toBe(false) }) it("refuses an empty cells array", () => { @@ -2623,6 +2629,56 @@ describe("autoRefreshIntervalMs", () => { }) }) +describe("auto-refresh inheritance helpers", () => { + it("resolveAutoRefresh prefers the override, then the default, then adaptive", () => { + expect(resolveAutoRefresh("5s", "30s")).toBe("5s") + expect(resolveAutoRefresh(undefined, "30s")).toBe("30s") + expect(resolveAutoRefresh(undefined, undefined)).toBe(true) + }) + + it("resolveAutoRefresh treats false as a value, never as absent", () => { + expect(resolveAutoRefresh(false, "30s")).toBe(false) + expect(resolveAutoRefresh(undefined, false)).toBe(false) + }) + + it("countAutoRefreshOverrides counts stored keys on ANY mode, including dormant run-cell overrides", () => { + // Given a draw override, a dormant run-mode override, and an inheriting cell + const cells: NotebookCell[] = [ + { ...cell("a", "SELECT 1"), mode: "draw", autoRefresh: "5s" }, + { ...cell("b", "SELECT 2"), mode: "run", autoRefresh: false }, + cell("c", "SELECT 3"), + ] + // Then the count matches what a reset would clear + expect(countAutoRefreshOverrides(cells)).toBe(2) + expect(isAutoRefreshOverride(cells[1])).toBe(true) + expect(isAutoRefreshOverride(cells[2])).toBe(false) + }) + + it("clearCellAutoRefresh deletes the key so a later draw switch cannot resurrect it", () => { + // Given a run cell carrying a dormant override + const dormant: NotebookCell = { + ...cell("a", "SELECT 1"), + mode: "run", + autoRefresh: "1s", + } + // When the override clears and the cell later switches to draw + const cleared = clearCellAutoRefresh(dormant) + const redrawn: NotebookCell = { + ...cleared, + mode: "draw", + ...cellModeChangePatch(cleared, "draw"), + } + // Then no key exists at any point + expect("autoRefresh" in cleared).toBe(false) + expect("autoRefresh" in redrawn).toBe(false) + }) + + it("clearCellAutoRefresh returns the same cell when no override exists", () => { + const c = cell("a", "SELECT 1") + expect(clearCellAutoRefresh(c)).toBe(c) + }) +}) + describe("resolveCellView", () => { const result = { results: [], activeResultIndex: 0, timestamp: 0 } it("is chart whenever the cell is in draw mode", () => { @@ -3084,6 +3140,29 @@ describe("buildAppliedNotebookState", () => { expect(next.diff.deleted).toEqual([]) }) + it("applies auto_refresh_default and preserves it on null", () => { + // Given a notebook with a stored notebook-level default + const current = { + cells: [cell("a", "SELECT 1")], + settings: { autoRefreshDefault: "30s" as const }, + maximizedCellId: null, + } + // When apply sets a new default + const set = buildAppliedNotebookState(current, { + autoRefreshDefault: "5s", + cells: [{ id: "a", preserveValue: true }], + }) + // Then the new default is stored + expect(set.settings.autoRefreshDefault).toBe("5s") + // When apply passes null + const preserved = buildAppliedNotebookState(current, { + autoRefreshDefault: null, + cells: [{ id: "a", preserveValue: true }], + }) + // Then the stored default survives + expect(preserved.settings.autoRefreshDefault).toBe("30s") + }) + it("grid layout mode builds a layout for every cell", () => { // Given a list-mode notebook const current = state([cell("a", "SELECT 1")]) diff --git a/src/scenes/Editor/Notebook/notebookUtils.ts b/src/scenes/Editor/Notebook/notebookUtils.ts index f71c7e82f..3efb0abe8 100644 --- a/src/scenes/Editor/Notebook/notebookUtils.ts +++ b/src/scenes/Editor/Notebook/notebookUtils.ts @@ -51,7 +51,26 @@ export const autoRefreshIntervalMs = ( export const isAutoRefresh = (value: unknown): value is AutoRefresh => typeof value === "boolean" || - (typeof value === "string" && value in AUTO_REFRESH_INTERVALS) + (typeof value === "string" && + Object.prototype.hasOwnProperty.call(AUTO_REFRESH_INTERVALS, value)) + +export const resolveAutoRefresh = ( + cellValue: AutoRefresh | undefined, + notebookDefault: AutoRefresh | undefined, +): AutoRefresh => cellValue ?? notebookDefault ?? true + +export const isAutoRefreshOverride = ( + cell: Pick, +): boolean => cell.autoRefresh !== undefined + +export const countAutoRefreshOverrides = (cells: NotebookCell[]): number => + cells.filter(isAutoRefreshOverride).length + +export const clearCellAutoRefresh = (cell: NotebookCell): NotebookCell => { + if (!isAutoRefreshOverride(cell)) return cell + const { autoRefresh: _, ...rest } = cell + return rest +} // What a cell currently shows in its bottom slot — drives the toolbar's // view-switch / refresh / chart actions and their disabled states. @@ -655,6 +674,7 @@ type ApplyCellRequest = { type ApplyRequest = { layoutMode?: "list" | "grid" | null + autoRefreshDefault?: AutoRefresh | null maximizedCellId?: string | null variables?: NotebookVariable[] | null cells: ApplyCellRequest[] @@ -920,8 +940,7 @@ export const buildAppliedCells = ( : isDraw ? true : undefined - const autoRefresh = - req.autoRefresh != null ? req.autoRefresh : isDraw ? true : undefined + const autoRefresh = req.autoRefresh != null ? req.autoRefresh : undefined if (existing) { updated.push(existing.id) @@ -1545,6 +1564,15 @@ export const buildAppliedNotebookState = ( } else if (request.layoutMode !== undefined && request.layoutMode !== null) { nextSettings = { ...nextSettings, layoutMode: request.layoutMode } } + if ( + request.autoRefreshDefault !== undefined && + request.autoRefreshDefault !== null + ) { + nextSettings = { + ...nextSettings, + autoRefreshDefault: request.autoRefreshDefault, + } + } if (request.variables !== undefined) { nextSettings = { ...nextSettings, variables: request.variables ?? [] } } diff --git a/src/scenes/Editor/Notebook/refreshSplitButton.tsx b/src/scenes/Editor/Notebook/refreshSplitButton.tsx new file mode 100644 index 000000000..3132a0e94 --- /dev/null +++ b/src/scenes/Editor/Notebook/refreshSplitButton.tsx @@ -0,0 +1,57 @@ +import styled from "styled-components" +import { Button } from "../../../components" + +export const SplitButtonContainer = styled.div` + display: flex; + align-items: center; + border-radius: 0.4rem; + background: ${({ theme }) => theme.color.backgroundLighter}; + border: 1px solid ${({ theme }) => `${theme.color.selection}80`}; +` + +export const SplitSide = styled(Button)` + display: flex; + align-items: center; + gap: 0.5rem; + height: 3rem; + padding: 0 1.1rem; + border: none; + border-radius: 0; + color: ${({ theme }) => theme.color.foreground}; + font-size: 1.4rem; + cursor: pointer; + + svg { + width: 1.8rem; + height: 1.8rem; + } + + &&:hover:not(:disabled) { + background: ${({ theme }) => `${theme.color.selection}80`}; + color: ${({ theme }) => theme.color.foreground}; + } + + &:disabled { + opacity: 0.5; + } +` + +export const SplitDivider = styled.div` + width: 1px; + align-self: stretch; + margin: 0; + background: ${({ theme }) => theme.color.selection}; +` + +export const IntervalLabel = styled.span` + color: ${({ theme }) => theme.color.gray2}; +` + +export const OverrideDot = styled.span` + width: 0.6rem; + height: 0.6rem; + border-radius: 50%; + background: ${({ theme }) => theme.color.pinkPrimary}; + flex-shrink: 0; + margin-right: 0.3rem; +` diff --git a/src/scenes/Editor/Notebook/useCellsStore.ts b/src/scenes/Editor/Notebook/useCellsStore.ts index b569d1efb..724011e96 100644 --- a/src/scenes/Editor/Notebook/useCellsStore.ts +++ b/src/scenes/Editor/Notebook/useCellsStore.ts @@ -1,7 +1,11 @@ import { useCallback, useRef, useState } from "react" import type { ChartConfig } from "./CellChart/chartTypes" import type { NotebookCell, SingleQueryResult } from "../../../store/notebook" -import { attachScriptSummary, setResultAt } from "./notebookUtils" +import { + attachScriptSummary, + clearCellAutoRefresh, + setResultAt, +} from "./notebookUtils" import type { AutoRefresh } from "../../../store/notebook" type Options = { @@ -85,9 +89,16 @@ export const useCellsStore = ({ initialCells, persistCells }: Options) => { ) const setCellRefresh = useCallback( - (cellId: string, value: AutoRefresh) => - updateCell(cellId, { autoRefresh: value }), - [updateCell], + (cellId: string, value: AutoRefresh | undefined) => { + if (value === undefined) { + updateCells((prev) => + prev.map((c) => (c.id === cellId ? clearCellAutoRefresh(c) : c)), + ) + } else { + updateCell(cellId, { autoRefresh: value }) + } + }, + [updateCell, updateCells], ) return { diff --git a/src/store/notebook.ts b/src/store/notebook.ts index 58ffbacdf..65599c325 100644 --- a/src/store/notebook.ts +++ b/src/store/notebook.ts @@ -29,6 +29,7 @@ export const AUTO_REFRESH_INTERVALS = { } as const export type AutoRefreshInterval = keyof typeof AUTO_REFRESH_INTERVALS +// false means "Off", true means "Auto" — presence checks must be `!== undefined`. export type AutoRefresh = boolean | AutoRefreshInterval // Cell kind. `undefined` means "sql" everywhere — code only ever tests @@ -131,6 +132,7 @@ export type NotebookSettings = { layoutMode?: NotebookLayoutMode layout?: CellLayoutItem[] variables?: NotebookVariable[] + autoRefreshDefault?: AutoRefresh } export type NotebookViewState = { diff --git a/src/utils/ai/executeAIFlow.buildUserMessage.test.ts b/src/utils/ai/executeAIFlow.buildUserMessage.test.ts index fc48b95e7..842f0b90e 100644 --- a/src/utils/ai/executeAIFlow.buildUserMessage.test.ts +++ b/src/utils/ai/executeAIFlow.buildUserMessage.test.ts @@ -15,6 +15,7 @@ const okSnapshot: NotebookContextSnapshot = { buffer_id: 42, label: "Trades", layout_mode: "list", + auto_refresh_default: true, maximized_cell_id: null, cells: [], } diff --git a/src/utils/ai/executeAIFlow.notebookFreshness.test.ts b/src/utils/ai/executeAIFlow.notebookFreshness.test.ts index 03ecfcd85..7bacddd22 100644 --- a/src/utils/ai/executeAIFlow.notebookFreshness.test.ts +++ b/src/utils/ai/executeAIFlow.notebookFreshness.test.ts @@ -38,6 +38,7 @@ describe("buildNotebookFreshness — per-turn flow freshness tracker", () => { buffer_id: 3, label: "Trades", layout_mode: "list", + auto_refresh_default: true, maximized_cell_id: null, cells: [], } diff --git a/src/utils/ai/notebookSnapshot.test.ts b/src/utils/ai/notebookSnapshot.test.ts index 0e6f43838..e707d66d9 100644 --- a/src/utils/ai/notebookSnapshot.test.ts +++ b/src/utils/ai/notebookSnapshot.test.ts @@ -236,6 +236,34 @@ describe("buildSnapshot", () => { } }) + it("always populates auto_refresh_default — effective true when unset, the stored value when set", async () => { + const cells = [sql("a", "SELECT 1")] + const unsetId = await seedNotebook({ cells }) + const storedId = await seedNotebook({ + cells, + settings: { autoRefreshDefault: "30s" }, + }) + const offId = await seedNotebook({ + cells, + settings: { autoRefreshDefault: false }, + }) + const unset = await buildSnapshot(unsetId) + const stored = await buildSnapshot(storedId) + const off = await buildSnapshot(offId) + if ( + unset?.status === "ok" && + stored?.status === "ok" && + off?.status === "ok" + ) { + // The agent always sees one concrete effective value. + expect(unset.auto_refresh_default).toBe(true) + expect(stored.auto_refresh_default).toBe("30s") + expect(off.auto_refresh_default).toBe(false) + } else { + throw new Error("expected ok snapshots") + } + }) + it("surfaces the full chart config in wire shape (for PUT round-trip) without leaking series data", async () => { const cell = sql("a", "SELECT 1", { mode: "draw", @@ -375,6 +403,14 @@ describe("formatDigest", () => { expect(out).toContain("layout_mode: grid") expect(out).toContain("notebook_status: archived") }) + + it("prints an Off autorefresh default — false is a value, not an absence", () => { + // Given a digest whose only change is the notebook default going Off + const d = createEmptyDigest() + d.autoRefreshDefaultTo = false + // Then the block states it (a truthiness guard would drop this line) + expect(formatDigest(d)).toContain("auto_refresh_default: false") + }) }) describe("formatNotebookContextPrefix", () => { @@ -389,6 +425,7 @@ describe("formatNotebookContextPrefix", () => { buffer_id: 1, label: "x", layout_mode: "list", + auto_refresh_default: true, maximized_cell_id: null, cells: [], } @@ -406,6 +443,7 @@ describe("formatNotebookContextPrefix", () => { buffer_id: 2, label: "Notebook 1", layout_mode: "list", + auto_refresh_default: true, maximized_cell_id: null, cells: [], } diff --git a/src/utils/ai/notebookSnapshot.ts b/src/utils/ai/notebookSnapshot.ts index 1d62f5cd7..98eb8ffcc 100644 --- a/src/utils/ai/notebookSnapshot.ts +++ b/src/utils/ai/notebookSnapshot.ts @@ -59,6 +59,7 @@ export type NotebookContextSnapshot = buffer_id: number label: string layout_mode: "list" | "grid" + auto_refresh_default: AutoRefresh maximized_cell_id: string | null variables?: Array<{ name: string; value: string }> cells: NotebookContextCell[] @@ -184,6 +185,7 @@ export const buildSnapshot = async ( buffer_id: bufferId, label: meta.label, layout_mode: layoutMode, + auto_refresh_default: settings.autoRefreshDefault ?? true, maximized_cell_id: maximizedCellId, cells: cells.map((c) => buildCell(c, gridByCellId, layoutMode)), } @@ -217,6 +219,7 @@ export const formatSnapshot = (snap: NotebookContextSnapshot): string => { lines.push(` buffer_id: ${snap.buffer_id}`) lines.push(` label: ${JSON.stringify(sanitizeForPromptContext(snap.label))}`) lines.push(` layout_mode: ${snap.layout_mode}`) + lines.push(` auto_refresh_default: ${snap.auto_refresh_default}`) lines.push( ` maximized_cell_id: ${ snap.maximized_cell_id ? JSON.stringify(snap.maximized_cell_id) : "null" @@ -289,6 +292,8 @@ export const formatDigest = (digest: UserActionDigest): string => { parts.push(` ran: { ${entries} }`) } if (digest.layoutModeTo) parts.push(` layout_mode: ${digest.layoutModeTo}`) + if (digest.autoRefreshDefaultTo !== undefined) + parts.push(` auto_refresh_default: ${digest.autoRefreshDefaultTo}`) if (digest.notebookStatusChange) parts.push(` notebook_status: ${digest.notebookStatusChange}`) if (parts.length === 0) return "" diff --git a/src/utils/ai/prompts.ts b/src/utils/ai/prompts.ts index c4eb9e9a3..00e600a5a 100644 --- a/src/utils/ai/prompts.ts +++ b/src/utils/ai/prompts.ts @@ -82,7 +82,7 @@ export const NOTEBOOK_INSTRUCTION = ` ## Notebook Authoring You can create and edit QuestDB notebooks (tabs of SQL cells with list/grid layouts, draw-mode charts, and markdown prose cells) using these tools: -create_notebook, list_cells, get_cell, get_notebook_state, add_cell, update_cell, delete_cell, move_cell_up, move_cell_down, duplicate_cell, run_cell, set_layout_mode, set_cell_layout, set_cell_mode, set_cell_name, set_cell_chart_config, set_cell_autorefresh, set_cell_view_maximized, set_cell_maximized. +create_notebook, list_cells, get_cell, get_notebook_state, add_cell, update_cell, delete_cell, move_cell_up, move_cell_down, duplicate_cell, run_cell, set_layout_mode, set_cell_layout, set_cell_mode, set_cell_name, set_cell_chart_config, set_notebook_autorefresh, set_cell_autorefresh, set_cell_view_maximized, set_cell_maximized. CRITICAL — Do NOT expose buffer_id to the user - buffer_id is an internal identifier. NEVER ask the user for it, print it back, or mention it in any response. diff --git a/src/utils/ai/shared.notebookTools.test.ts b/src/utils/ai/shared.notebookTools.test.ts index 3f779a3b3..c4f5ac78b 100644 --- a/src/utils/ai/shared.notebookTools.test.ts +++ b/src/utils/ai/shared.notebookTools.test.ts @@ -502,6 +502,57 @@ describe("dispatchTool — notebook tools (happy path)", () => { expect(cellById(state, "c")?.autoRefresh).toBeUndefined() }) + it("set_cell_autorefresh with null DELETES the override key so the cell inherits", async () => { + // Given a cell carrying a stored override + const { state } = mountLive(1, [ + cell("c", "SELECT 1", { autoRefresh: "5s" }), + ]) + // When the agent clears it + await dispatchTool( + "set_cell_autorefresh", + { buffer_id: 1, cell_id: "c", value: null }, + makeClient(), + noopStatus, + ) + // Then no key remains — a spread patch would have kept it + const updated = cellById(state, "c") + expect(updated && "autoRefresh" in updated).toBe(false) + }) + + it("set_notebook_autorefresh stores the notebook default in settings", async () => { + const { state } = mountLive(1, [cell("c")]) + await dispatchTool( + "set_notebook_autorefresh", + { buffer_id: 1, value: "30s" }, + makeClient(), + noopStatus, + ) + expect(state.parts.settings.autoRefreshDefault).toBe("30s") + }) + + it("set_notebook_autorefresh accepts Off (false) as a value", async () => { + const { state } = mountLive(1, [cell("c")]) + await dispatchTool( + "set_notebook_autorefresh", + { buffer_id: 1, value: false }, + makeClient(), + noopStatus, + ) + expect(state.parts.settings.autoRefreshDefault).toBe(false) + }) + + it("set_notebook_autorefresh rejects a token outside the allowed set", async () => { + const { state } = mountLive(1, [cell("c")]) + const res = await dispatchTool( + "set_notebook_autorefresh", + { buffer_id: 1, value: "2s" }, + makeClient(), + noopStatus, + ) + expect(res.is_error).toBe(true) + expect(state.parts.settings.autoRefreshDefault).toBeUndefined() + }) + it("set_cell_name sets the cell name", async () => { const { state } = mountLive(1, [cell("c")]) await dispatchTool( @@ -864,6 +915,78 @@ describe("dispatchTool — notebook tools (happy path)", () => { expect(parsed.applied.deleted).toEqual(["c"]) }) + it("apply_notebook_state stores auto_refresh_default, including Off; null preserves", async () => { + const { state } = mountLive(1, [cell("a", "SELECT 1")]) + // When an apply sets the default to Off — false must survive as a value + await dispatchTool( + "apply_notebook_state", + { + buffer_id: 1, + auto_refresh_default: false, + cells: [{ id: "a", preserve_value: true }], + }, + makeClient(), + noopStatus, + ) + expect(state.parts.settings.autoRefreshDefault).toBe(false) + // When a later apply passes null + await dispatchTool( + "apply_notebook_state", + { + buffer_id: 1, + auto_refresh_default: null, + cells: [{ id: "a", preserve_value: true }], + }, + makeClient(), + noopStatus, + ) + // Then the stored default survives + expect(state.parts.settings.autoRefreshDefault).toBe(false) + }) + + it("apply_notebook_state rejects an invalid auto_refresh_default before mutating", async () => { + const { state } = mountLive(1, [cell("a", "SELECT 1")]) + const res = await dispatchTool( + "apply_notebook_state", + { + buffer_id: 1, + auto_refresh_default: "2s", + cells: [{ value: "SELECT 2" }], + }, + makeClient(), + noopStatus, + ) + // Then the tool errors and nothing committed + expect(res.is_error).toBe(true) + expect((JSON.parse(res.content) as { message: string }).message).toContain( + "auto_refresh_default", + ) + expect(cellById(state, "a")?.value).toBe("SELECT 1") + expect(state.parts.settings.autoRefreshDefault).toBeUndefined() + }) + + it("apply_notebook_state rejects an invalid per-cell auto_refresh instead of wiping the override", async () => { + // Given a cell the user set to Off + const { state } = mountLive(1, [ + cell("a", "SELECT 1", { autoRefresh: false }), + ]) + const res = await dispatchTool( + "apply_notebook_state", + { + buffer_id: 1, + cells: [{ id: "a", preserve_value: true, auto_refresh: "2sec" }], + }, + makeClient(), + noopStatus, + ) + // Then the typo errors instead of silently deleting the override + expect(res.is_error).toBe(true) + expect((JSON.parse(res.content) as { message: string }).message).toContain( + "cells[0].auto_refresh", + ) + expect(cellById(state, "a")?.autoRefresh).toBe(false) + }) + it("apply_notebook_state applies ordered variables; null preserves, [] clears", async () => { const variables = [ { name: "x", value: "10" }, diff --git a/src/utils/mcp/dispatchMCPTool.test.ts b/src/utils/mcp/dispatchMCPTool.test.ts index 888cf140a..f6858f416 100644 --- a/src/utils/mcp/dispatchMCPTool.test.ts +++ b/src/utils/mcp/dispatchMCPTool.test.ts @@ -175,6 +175,7 @@ describe("dispatchMCPTool — state-freshness gate", () => { "set_cell_layout", "set_cell_mode", "set_cell_chart_config", + "set_notebook_autorefresh", "set_cell_autorefresh", "set_cell_view_maximized", "set_cell_maximized", diff --git a/src/utils/notebooks/notebookAIBridge.ts b/src/utils/notebooks/notebookAIBridge.ts index 23d36042f..10c45fe98 100644 --- a/src/utils/notebooks/notebookAIBridge.ts +++ b/src/utils/notebooks/notebookAIBridge.ts @@ -1,4 +1,4 @@ -import type { CellMode } from "../../store/notebook" +import type { AutoRefresh, CellMode } from "../../store/notebook" import type { RanStatus } from "../ai/runStatus" import type { Client } from "../questdb/client" @@ -135,6 +135,11 @@ export type UserActionEvent = bufferId: number mode: "list" | "grid" } + | { + kind: "user_changed_autorefresh_default" + bufferId: number + value: AutoRefresh + } | { kind: "user_changed_cell_mode" bufferId: number diff --git a/src/utils/notebooks/notebookController/index.ts b/src/utils/notebooks/notebookController/index.ts index 0784ed768..29e5228f9 100644 --- a/src/utils/notebooks/notebookController/index.ts +++ b/src/utils/notebooks/notebookController/index.ts @@ -115,6 +115,7 @@ export { migratePersistedNotebookView } from "../notebookDexieView" export { addCellTransition, applyNotebookStateTransition, + clearCellAutoRefreshTransition, deleteCellTransition, duplicateCellTransition, moveCellDownTransition, @@ -125,6 +126,7 @@ export { setCellModeTransition, setCellViewMaximizedTransition, setLayoutModeTransition, + setNotebookAutoRefreshTransition, updateCellTransition, type NotebookTransitionResult, } from "./notebookTransitions" diff --git a/src/utils/notebooks/notebookController/notebookController.ts b/src/utils/notebooks/notebookController/notebookController.ts index 11557b2f0..927649bd1 100644 --- a/src/utils/notebooks/notebookController/notebookController.ts +++ b/src/utils/notebooks/notebookController/notebookController.ts @@ -109,6 +109,7 @@ export type ApplyNotebookStateCellRequest = { export type ApplyNotebookStateRequest = { layoutMode?: "list" | "grid" | null + autoRefreshDefault?: AutoRefresh | null maximizedCellId?: string | null variables?: NotebookVariable[] | null cells: ApplyNotebookStateCellRequest[] diff --git a/src/utils/notebooks/notebookController/notebookTransitions.ts b/src/utils/notebooks/notebookController/notebookTransitions.ts index c49550d85..7061dd5ba 100644 --- a/src/utils/notebooks/notebookController/notebookTransitions.ts +++ b/src/utils/notebooks/notebookController/notebookTransitions.ts @@ -1,5 +1,6 @@ import { MAX_NOTEBOOK_CELLS, + type AutoRefresh, type CellMode, type CellType, type NotebookCell, @@ -13,6 +14,7 @@ import { buildAppliedNotebookState, cellHeightPatchForRows, cellModeChangePatch, + clearCellAutoRefresh, duplicateCellAt, insertCell, isExpectingResult, @@ -224,6 +226,35 @@ export const setLayoutModeTransition = ( result: undefined, }) +export const setNotebookAutoRefreshTransition = ( + parts: ViewParts, + value: AutoRefresh, +): NotebookTransitionResult => ({ + parts: { + ...parts, + settings: { ...parts.settings, autoRefreshDefault: value }, + }, + result: undefined, +}) + +export const clearCellAutoRefreshTransition = ( + parts: ViewParts, + bufferId: number, + cellId: string, +): NotebookTransitionResult => { + requireCellIn(parts.cells, cellId, bufferId) + return { + parts: { + ...parts, + cells: parts.cells.map((c) => + c.id === cellId ? clearCellAutoRefresh(c) : c, + ), + }, + result: undefined, + touchedCellId: cellId, + } +} + export const setCellLayoutTransition = ( parts: ViewParts, bufferId: number, diff --git a/src/utils/tools/applyNotebookState.ts b/src/utils/tools/applyNotebookState.ts index 4b6a94505..4f4ddddcf 100644 --- a/src/utils/tools/applyNotebookState.ts +++ b/src/utils/tools/applyNotebookState.ts @@ -198,10 +198,18 @@ export const dispatchApplyNotebookState = async ( signal: AbortSignal | undefined, toolContext: ToolExecutionContext | undefined, ): Promise<{ content: string; is_error?: boolean }> => { - const { buffer_id, layout_mode, maximized_cell_id, variables, cells } = + const { + buffer_id, + layout_mode, + auto_refresh_default, + maximized_cell_id, + variables, + cells, + } = (input as { buffer_id: number layout_mode?: "list" | "grid" | null + auto_refresh_default?: boolean | string | null maximized_cell_id?: string | null variables?: NotebookVariable[] | null cells: Array<{ @@ -250,7 +258,33 @@ export const dispatchApplyNotebookState = async ( is_error: true, } } + if ( + auto_refresh_default !== undefined && + auto_refresh_default !== null && + !isAutoRefresh(auto_refresh_default) + ) { + return { + content: JSON.stringify({ + error_code: "validation", + message: `VALIDATION_ERROR: auto_refresh_default must be true, false, null, or one of "1s", "5s", "10s", "30s", "1m".`, + }), + is_error: true, + } + } for (const [idx, c] of cells.entries()) { + if ( + c.auto_refresh !== undefined && + c.auto_refresh !== null && + !isAutoRefresh(c.auto_refresh) + ) { + return { + content: JSON.stringify({ + error_code: "validation", + message: `VALIDATION_ERROR: cells[${idx}].auto_refresh must be true, false, null, or one of "1s", "5s", "10s", "30s", "1m".`, + }), + is_error: true, + } + } const hasValue = typeof c.value === "string" const preserves = c.preserve_value === true if (preserves === hasValue) { @@ -344,6 +378,9 @@ export const dispatchApplyNotebookState = async ( } const request: ApplyNotebookStateRequest = { layoutMode: layout_mode ?? null, + autoRefreshDefault: isAutoRefresh(auto_refresh_default) + ? auto_refresh_default + : null, maximizedCellId: maximized_cell_id === undefined ? undefined : maximized_cell_id, variables: diff --git a/src/utils/tools/dispatch.ts b/src/utils/tools/dispatch.ts index 173b2cc6a..1e54d136f 100644 --- a/src/utils/tools/dispatch.ts +++ b/src/utils/tools/dispatch.ts @@ -63,6 +63,7 @@ import { withBoundNotebook, withBoundNotebookReadOnly, addCellTransition, + clearCellAutoRefreshTransition, deleteCellTransition, duplicateCellTransition, moveCellDownTransition, @@ -73,6 +74,7 @@ import { setCellModeTransition, setCellViewMaximizedTransition, setLayoutModeTransition, + setNotebookAutoRefreshTransition, updateCellTransition, type ViewParts, type NotebookTransitionResult, @@ -964,11 +966,11 @@ export const dispatchTool = async ( cell_id: string value: unknown }) || {} - if (!isAutoRefresh(value)) { + if (value !== null && !isAutoRefresh(value)) { return { content: JSON.stringify({ error_code: "validation", - message: `VALIDATION_ERROR: value must be true, false, or one of "1s", "5s", "10s", "30s", "1m".`, + message: `VALIDATION_ERROR: value must be true, false, null, or one of "1s", "5s", "10s", "30s", "1m".`, }), is_error: true, } @@ -978,9 +980,32 @@ export const dispatchTool = async ( runTransition( buffer_id, (parts) => - updateCellTransition(parts, buffer_id, cell_id, { - autoRefresh: value, - }), + value === null + ? clearCellAutoRefreshTransition(parts, buffer_id, cell_id) + : updateCellTransition(parts, buffer_id, cell_id, { + autoRefresh: value, + }), + signal, + ), + ) + } + case "set_notebook_autorefresh": { + const { buffer_id, value } = + (input as { buffer_id: number; value: unknown }) || {} + if (!isAutoRefresh(value)) { + return { + content: JSON.stringify({ + error_code: "validation", + message: `VALIDATION_ERROR: value must be true, false, or one of "1s", "5s", "10s", "30s", "1m".`, + }), + is_error: true, + } + } + setStatus(AIOperationStatus.ConfiguringChart) + return routeNotebookTool(() => + runTransition( + buffer_id, + (parts) => setNotebookAutoRefreshTransition(parts, value), signal, ), ) From 88c45ddb8b00ce08f640a379780cdda0fc229c7f Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 4 Aug 2026 10:25:57 +0300 Subject: [PATCH 02/17] reviews --- e2e/questdb | 2 +- src/components/DropdownMenu/index.tsx | 51 +++++- .../Notebook/cells/AutoRefreshOptions.tsx | 6 +- .../chartRefresh/chartRefreshEngine.test.ts | 21 +++ .../chartRefresh/chartRefreshEngine.ts | 15 +- .../Editor/Notebook/notebookUtils.test.ts | 9 + .../Editor/Notebook/refreshSplitButton.tsx | 1 - src/store/notebook.test.ts | 157 ++++++++++++++++++ src/store/notebook.ts | 28 ++++ src/utils/ai/notebookSnapshot.test.ts | 6 +- src/utils/notebooks/notebookDexieView.ts | 3 +- 11 files changed, 285 insertions(+), 14 deletions(-) diff --git a/e2e/questdb b/e2e/questdb index 0e12b8665..8fb0a75c1 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 0e12b8665922b827bbcd4099e3d59193ed4a62d8 +Subproject commit 8fb0a75c136124ddcd24756d21fc756f8e0b408a diff --git a/src/components/DropdownMenu/index.tsx b/src/components/DropdownMenu/index.tsx index 2de8bd2d7..5581d2e46 100644 --- a/src/components/DropdownMenu/index.tsx +++ b/src/components/DropdownMenu/index.tsx @@ -1,6 +1,6 @@ import React from "react" import * as RadixDropdownMenu from "@radix-ui/react-dropdown-menu" -import { CaretRightIcon } from "@phosphor-icons/react" +import { CaretRightIcon, CheckIcon } from "@phosphor-icons/react" import styled from "styled-components" import { menuContainerStyles, @@ -22,7 +22,7 @@ const StyledItem = styled(RadixDropdownMenu.Item)` ${menuItemStyles} ` -const RadioItem = styled(RadixDropdownMenu.RadioItem)` +const StyledRadioItem = styled(RadixDropdownMenu.RadioItem)` ${menuItemStyles} &[data-state="checked"] { @@ -30,6 +30,53 @@ const RadioItem = styled(RadixDropdownMenu.RadioItem)` } ` +// The checked background alone is the same token as the highlighted one, so +// selection and keyboard focus are otherwise indistinguishable. The slot is +// always rendered to keep every label on one left edge. +const RadioItemIndicator = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 1.4rem; + color: ${({ theme }) => theme.color.pinkPrimary}; + + span { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + svg { + width: 1.4rem; + height: 1.4rem; + } +` + +type RadioItemProps = React.ComponentPropsWithoutRef< + typeof RadixDropdownMenu.RadioItem +> & { + // Shares the checkmark's slot: a row showing one is never the checked row. + indicator?: React.ReactNode +} + +const RadioItem = React.forwardRef< + React.ElementRef, + RadioItemProps +>(({ indicator, children, ...props }, ref) => ( + + + + + + {indicator} + + {children} + +)) + +RadioItem.displayName = "DropdownMenuRadioItem" + type ItemProps = React.ComponentPropsWithoutRef< typeof RadixDropdownMenu.Item > & { diff --git a/src/scenes/Editor/Notebook/cells/AutoRefreshOptions.tsx b/src/scenes/Editor/Notebook/cells/AutoRefreshOptions.tsx index a7234c071..bcbe3dc03 100644 --- a/src/scenes/Editor/Notebook/cells/AutoRefreshOptions.tsx +++ b/src/scenes/Editor/Notebook/cells/AutoRefreshOptions.tsx @@ -37,8 +37,10 @@ export const AutoRefreshOptions: React.FC = ({ > {inheritedValue !== undefined && ( <> - - {value !== undefined && } + : undefined} + > {`Notebook default (${autoRefreshLabel(inheritedValue)})`} diff --git a/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.test.ts b/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.test.ts index f7717ce07..f64b1062a 100644 --- a/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.test.ts +++ b/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.test.ts @@ -1892,5 +1892,26 @@ describe("ChartRefreshEngine", () => { await vi.advanceTimersByTimeAsync(1000) expect(deps.executeSingle).toHaveBeenCalledTimes(2) }) + + it("still refetches when a mid-edit only changes the SQL cosmetically", async () => { + // Given a settled Off cell edited in a way that leaves the queries equal, + // so the promoted SQL could settle from the existing frame + syncOnScreen([drawCell("c1", "select 1", false)]) + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + engine.sync([drawCell("c1", "select 1;\n", false)]) + + // When the user clicks refresh-all inside the debounce window + engine.refreshAll() + await flushAsync() + + // Then the click still produces a real fetch, rather than settling from + // the frame the equal queries already hold + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + + // And the debounce expiry adds nothing + await vi.advanceTimersByTimeAsync(1000) + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + }) }) }) diff --git a/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.ts b/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.ts index 3d062e5b2..aa97bdf3e 100644 --- a/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.ts +++ b/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.ts @@ -294,7 +294,7 @@ export class ChartRefreshEngine { return } entry.pendingManualRefresh = false - if (this.promotePendingSql(entry)) return + this.promotePendingSql(entry) if (this.shouldPoll(entry)) { entry.lastFetchedAt = 0 entry.poll?.abort() @@ -306,16 +306,15 @@ export class ChartRefreshEngine { } } - private promotePendingSql(entry: Entry): boolean { + private promotePendingSql(entry: Entry) { if (entry.sqlDebounce) { clearTimeout(entry.sqlDebounce) entry.sqlDebounce = null } const sql = entry.pendingSql entry.pendingSql = null - if (sql == null || sql === entry.sql) return false - this.applySql(entry, sql) - return true + if (sql == null || sql === entry.sql) return + this.applySqlState(entry, sql) } // Called by the notebook's cell visibility observer. Hiding pauses the poll @@ -434,7 +433,7 @@ export class ChartRefreshEngine { this.notify(cellId) } - private applySql(entry: Entry, sql: string) { + private applySqlState(entry: Entry, sql: string) { entry.inFlight?.abort() entry.inFlight = null this.stopResultLoadWait(entry) @@ -457,6 +456,10 @@ export class ChartRefreshEngine { fetching: false, ...(sameQueries ? { settledKey: queriesKey } : {}), }) + } + + private applySql(entry: Entry, sql: string) { + this.applySqlState(entry, sql) if (entry.visible) this.ensureData(entry) else entry.ensureAttempted = false } diff --git a/src/scenes/Editor/Notebook/notebookUtils.test.ts b/src/scenes/Editor/Notebook/notebookUtils.test.ts index d5026ccf4..9242140dd 100644 --- a/src/scenes/Editor/Notebook/notebookUtils.test.ts +++ b/src/scenes/Editor/Notebook/notebookUtils.test.ts @@ -2608,6 +2608,15 @@ describe("isAutoRefresh", () => { expect(isAutoRefresh(null)).toBe(false) expect(isAutoRefresh(undefined)).toBe(false) }) + + it("rejects inherited Object property names", () => { + // An `in` check would accept these; each one reaches the poll-interval + // lookup as a function and degrades the cadence math to NaN. + expect(isAutoRefresh("toString")).toBe(false) + expect(isAutoRefresh("constructor")).toBe(false) + expect(isAutoRefresh("__proto__")).toBe(false) + expect(isAutoRefresh("hasOwnProperty")).toBe(false) + }) }) describe("autoRefreshLabel", () => { diff --git a/src/scenes/Editor/Notebook/refreshSplitButton.tsx b/src/scenes/Editor/Notebook/refreshSplitButton.tsx index 3132a0e94..08158665e 100644 --- a/src/scenes/Editor/Notebook/refreshSplitButton.tsx +++ b/src/scenes/Editor/Notebook/refreshSplitButton.tsx @@ -53,5 +53,4 @@ export const OverrideDot = styled.span` border-radius: 50%; background: ${({ theme }) => theme.color.pinkPrimary}; flex-shrink: 0; - margin-right: 0.3rem; ` diff --git a/src/store/notebook.test.ts b/src/store/notebook.test.ts index 4e547bc3e..d161301c4 100644 --- a/src/store/notebook.test.ts +++ b/src/store/notebook.test.ts @@ -2,7 +2,9 @@ import { describe, it, expect } from "vitest" import { dropLegacyChartConfigs, migrateCellName, + migrateLegacyAutoRefresh, migrateLegacyCellNames, + type AutoRefresh, type NotebookCell, type NotebookViewState, } from "./notebook" @@ -13,6 +15,13 @@ const cell = (over: Partial & { id: string }): NotebookCell => ({ ...over, }) +const chart = (id: string, autoRefresh?: AutoRefresh): NotebookCell => + cell( + autoRefresh === undefined + ? { id, mode: "draw" } + : { id, mode: "draw", autoRefresh }, + ) + describe("migrateCellName", () => { it("promotes a legacy chartConfig.name to the cell name and drops the old copy", () => { // Given a cell whose title lives on chartConfig.name @@ -47,6 +56,154 @@ describe("migrateCellName", () => { }) }) +describe("migrateLegacyAutoRefresh", () => { + const overrides = (state: NotebookViewState): string[] => + state.cells.filter((c) => c.autoRefresh !== undefined).map((c) => c.id) + + it("adopts the shared cadence as the notebook default when every chart agrees", () => { + // Given 10 legacy charts the user had all turned Off + const state: NotebookViewState = { + cells: Array.from({ length: 10 }, (_, i) => chart(`c${i}`, false)), + } + + // When the notebook loads + const result = migrateLegacyAutoRefresh(state) + + // Then the notebook reads Off and no chart claims to override it + expect(result.settings?.autoRefreshDefault).toBe(false) + expect(overrides(result)).toEqual([]) + }) + + it("leaves the default unset for all-adaptive charts, which already reads as Auto", () => { + // Given legacy AI charts, each persisted with an explicit true + const state: NotebookViewState = { + cells: [chart("a", true), chart("b", true), chart("c", true)], + } + + // When the notebook loads + const result = migrateLegacyAutoRefresh(state) + + // Then the phantom overrides are gone and no redundant key is stored + expect(result.settings?.autoRefreshDefault).toBeUndefined() + expect(overrides(result)).toEqual([]) + }) + + it("adopts a shared fixed interval", () => { + const state: NotebookViewState = { + cells: [chart("a", "5s"), chart("b", "5s")], + } + const result = migrateLegacyAutoRefresh(state) + expect(result.settings?.autoRefreshDefault).toBe("5s") + expect(overrides(result)).toEqual([]) + }) + + it("keeps an override only where a chart diverges from the resulting default", () => { + // Given legacy adaptive charts mixed with charts the user turned Off + const state: NotebookViewState = { + cells: [ + chart("legacy1", true), + chart("legacy2", true), + chart("off1", false), + chart("off2", false), + ], + } + + // When the notebook loads + const result = migrateLegacyAutoRefresh(state) + + // Then the default falls back to Auto and only the real divergence remains + expect(result.settings?.autoRefreshDefault).toBeUndefined() + expect(overrides(result)).toEqual(["off1", "off2"]) + }) + + it("marks every chart an override when they diverge with no adaptive majority", () => { + const state: NotebookViewState = { + cells: [chart("a", false), chart("b", "5s")], + } + const result = migrateLegacyAutoRefresh(state) + expect(result.settings?.autoRefreshDefault).toBeUndefined() + expect(overrides(result)).toEqual(["a", "b"]) + }) + + it("clears a dormant adaptive key on a run cell so it cannot inflate the count", () => { + // Given a cell that carried an override before switching out of draw mode + const state: NotebookViewState = { + cells: [cell({ id: "a", mode: "run", autoRefresh: true })], + } + + // Then the invisible key is gone, but a diverging one survives the switch + expect(overrides(migrateLegacyAutoRefresh(state))).toEqual([]) + expect( + overrides( + migrateLegacyAutoRefresh({ + cells: [cell({ id: "a", mode: "run", autoRefresh: "5s" })], + }), + ), + ).toEqual(["a"]) + }) + + it("only decides from charts, ignoring a dormant run-cell value", () => { + // Given every chart Off and an unrelated dormant key on a run cell + const state: NotebookViewState = { + cells: [ + chart("a", false), + chart("b", false), + cell({ id: "r", mode: "run", autoRefresh: "1m" }), + ], + } + + // Then the charts still set the default; the run cell does not vote + const result = migrateLegacyAutoRefresh(state) + expect(result.settings?.autoRefreshDefault).toBe(false) + expect(overrides(result)).toEqual(["r"]) + }) + + it("never touches a notebook that already has a stored default", () => { + // Given a post-upgrade notebook where the pinned cell is deliberate + const state: NotebookViewState = { + cells: [chart("a", true), chart("b", "5s")], + settings: { autoRefreshDefault: "30s" }, + } + + // Then the migration leaves it exactly as-is + expect(migrateLegacyAutoRefresh(state)).toBe(state) + }) + + it("returns the same state when no cell stores a value", () => { + const state: NotebookViewState = { cells: [chart("a"), chart("b")] } + expect(migrateLegacyAutoRefresh(state)).toBe(state) + }) + + it("is idempotent", () => { + const state: NotebookViewState = { + cells: [chart("a", false), chart("b", false)], + } + const once = migrateLegacyAutoRefresh(state) + expect(migrateLegacyAutoRefresh(once)).toBe(once) + }) + + it("preserves what every chart actually polls at", () => { + // Given a mix of stored, absent, and diverging values + const state: NotebookViewState = { + cells: [ + chart("a", true), + chart("b"), + chart("c", false), + chart("d", "5s"), + ], + } + const before = state.cells.map((c) => c.autoRefresh ?? true) + + // When migrated, each cell resolves against the new default + const result = migrateLegacyAutoRefresh(state) + const fallback = result.settings?.autoRefreshDefault ?? true + const after = result.cells.map((c) => c.autoRefresh ?? fallback) + + // Then every chart polls exactly as it did before + expect(after).toEqual(before) + }) +}) + describe("migrateLegacyCellNames composed with dropLegacyChartConfigs", () => { it("preserves the legacy name even when the chartConfig has no queries array", () => { // Given a pre-combine chart config (no `queries`) that still carries a title diff --git a/src/store/notebook.ts b/src/store/notebook.ts index 65599c325..6c2b4b49f 100644 --- a/src/store/notebook.ts +++ b/src/store/notebook.ts @@ -189,3 +189,31 @@ export const migrateLegacyCellNames = ( state.cells.some(hasLegacyChartName) ? { ...state, cells: state.cells.map(migrateCellName) } : state + +const isLegacyAutoRefreshNotebook = (state: NotebookViewState): boolean => + state.settings?.autoRefreshDefault === undefined && + state.cells.some((cell) => cell.autoRefresh !== undefined) + +export const migrateLegacyAutoRefresh = ( + state: NotebookViewState, +): NotebookViewState => { + if (!isLegacyAutoRefreshNotebook(state)) return state + const shown = state.cells + .filter((cell) => cell.mode === "draw") + .map((cell) => cell.autoRefresh ?? true) + const nextDefault = + shown.length > 0 && shown.every((value) => value === shown[0]) + ? shown[0] + : true + const cells = state.cells.map((cell) => { + if (cell.autoRefresh !== nextDefault) return cell + const next = { ...cell } + delete next.autoRefresh + return next + }) + const settings = + nextDefault === true + ? state.settings + : { ...state.settings, autoRefreshDefault: nextDefault } + return { ...state, cells, settings } +} diff --git a/src/utils/ai/notebookSnapshot.test.ts b/src/utils/ai/notebookSnapshot.test.ts index e707d66d9..4a084a712 100644 --- a/src/utils/ai/notebookSnapshot.test.ts +++ b/src/utils/ai/notebookSnapshot.test.ts @@ -275,7 +275,11 @@ describe("buildSnapshot", () => { queries: [{ type: "line", yColumns: ["price", "volume"] }], }, }) - const id = await seedNotebook({ cells: [cell] }) + // A stored notebook default keeps the cell's 5s a genuine override. + const id = await seedNotebook({ + cells: [cell], + settings: { autoRefreshDefault: true }, + }) const snap = await buildSnapshot(id) if (snap?.status === "ok") { // Snake-case wire shape the model can copy straight back into apply_notebook_state. diff --git a/src/utils/notebooks/notebookDexieView.ts b/src/utils/notebooks/notebookDexieView.ts index c6894e21c..a65d2c935 100644 --- a/src/utils/notebooks/notebookDexieView.ts +++ b/src/utils/notebooks/notebookDexieView.ts @@ -6,6 +6,7 @@ import { dropLegacyChartConfigs, exceedsCellLineLimit, MAX_CELL_LINES, + migrateLegacyAutoRefresh, migrateLegacyCellNames, } from "../../store/notebook" import type { @@ -27,7 +28,7 @@ type NotebookBufferMeta = | { kind: "not_a_notebook" } export const migratePersistedNotebookView = (view: NotebookViewState) => - dropLegacyChartConfigs(migrateLegacyCellNames(view)) + migrateLegacyAutoRefresh(dropLegacyChartConfigs(migrateLegacyCellNames(view))) export const readNotebookBufferMeta = async ( bufferId: number, From de9b3ed76adf00fdf0d2743c38872e05b88db4bb Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 4 Aug 2026 11:34:45 +0300 Subject: [PATCH 03/17] fix legacy autorefresh detection --- src/store/notebook.test.ts | 33 +++++++++++++++++++++++---------- src/store/notebook.ts | 20 ++++++++------------ 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/src/store/notebook.test.ts b/src/store/notebook.test.ts index d161301c4..1e33776a6 100644 --- a/src/store/notebook.test.ts +++ b/src/store/notebook.test.ts @@ -74,7 +74,7 @@ describe("migrateLegacyAutoRefresh", () => { expect(overrides(result)).toEqual([]) }) - it("leaves the default unset for all-adaptive charts, which already reads as Auto", () => { + it("keeps all-adaptive charts on Auto and drops their phantom overrides", () => { // Given legacy AI charts, each persisted with an explicit true const state: NotebookViewState = { cells: [chart("a", true), chart("b", true), chart("c", true)], @@ -83,8 +83,8 @@ describe("migrateLegacyAutoRefresh", () => { // When the notebook loads const result = migrateLegacyAutoRefresh(state) - // Then the phantom overrides are gone and no redundant key is stored - expect(result.settings?.autoRefreshDefault).toBeUndefined() + // Then the notebook reads Auto and nothing claims to override it + expect(result.settings?.autoRefreshDefault).toBe(true) expect(overrides(result)).toEqual([]) }) @@ -112,7 +112,7 @@ describe("migrateLegacyAutoRefresh", () => { const result = migrateLegacyAutoRefresh(state) // Then the default falls back to Auto and only the real divergence remains - expect(result.settings?.autoRefreshDefault).toBeUndefined() + expect(result.settings?.autoRefreshDefault).toBe(true) expect(overrides(result)).toEqual(["off1", "off2"]) }) @@ -121,7 +121,7 @@ describe("migrateLegacyAutoRefresh", () => { cells: [chart("a", false), chart("b", "5s")], } const result = migrateLegacyAutoRefresh(state) - expect(result.settings?.autoRefreshDefault).toBeUndefined() + expect(result.settings?.autoRefreshDefault).toBe(true) expect(overrides(result)).toEqual(["a", "b"]) }) @@ -158,6 +158,24 @@ describe("migrateLegacyAutoRefresh", () => { expect(overrides(result)).toEqual(["r"]) }) + it("stamps the default it resolved, so a later override is never mistaken for legacy data", () => { + // Given an untouched notebook — the one shape a deliberate override could + // otherwise be misread as legacy + const first = migrateLegacyAutoRefresh({ cells: [chart("a")] }) + expect(first.settings?.autoRefreshDefault).toBe(true) + + // When the user sets that single chart to Off and it loads again + const overridden: NotebookViewState = { + ...first, + cells: [{ ...first.cells[0], autoRefresh: false }], + } + const reloaded = migrateLegacyAutoRefresh(overridden) + + // Then the override survives instead of collapsing into the default + expect(reloaded.cells[0].autoRefresh).toBe(false) + expect(reloaded.settings?.autoRefreshDefault).toBe(true) + }) + it("never touches a notebook that already has a stored default", () => { // Given a post-upgrade notebook where the pinned cell is deliberate const state: NotebookViewState = { @@ -169,11 +187,6 @@ describe("migrateLegacyAutoRefresh", () => { expect(migrateLegacyAutoRefresh(state)).toBe(state) }) - it("returns the same state when no cell stores a value", () => { - const state: NotebookViewState = { cells: [chart("a"), chart("b")] } - expect(migrateLegacyAutoRefresh(state)).toBe(state) - }) - it("is idempotent", () => { const state: NotebookViewState = { cells: [chart("a", false), chart("b", false)], diff --git a/src/store/notebook.ts b/src/store/notebook.ts index 6c2b4b49f..dfab75995 100644 --- a/src/store/notebook.ts +++ b/src/store/notebook.ts @@ -190,30 +190,26 @@ export const migrateLegacyCellNames = ( ? { ...state, cells: state.cells.map(migrateCellName) } : state -const isLegacyAutoRefreshNotebook = (state: NotebookViewState): boolean => - state.settings?.autoRefreshDefault === undefined && - state.cells.some((cell) => cell.autoRefresh !== undefined) - export const migrateLegacyAutoRefresh = ( state: NotebookViewState, ): NotebookViewState => { - if (!isLegacyAutoRefreshNotebook(state)) return state + if (state.settings?.autoRefreshDefault !== undefined) return state const shown = state.cells .filter((cell) => cell.mode === "draw") .map((cell) => cell.autoRefresh ?? true) - const nextDefault = + const autoRefreshDefault = shown.length > 0 && shown.every((value) => value === shown[0]) ? shown[0] : true const cells = state.cells.map((cell) => { - if (cell.autoRefresh !== nextDefault) return cell + if (cell.autoRefresh !== autoRefreshDefault) return cell const next = { ...cell } delete next.autoRefresh return next }) - const settings = - nextDefault === true - ? state.settings - : { ...state.settings, autoRefreshDefault: nextDefault } - return { ...state, cells, settings } + return { + ...state, + cells, + settings: { ...state.settings, autoRefreshDefault }, + } } From 810bd630d12eb0d3ab97dc0aeb5c96b29e74760d Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 4 Aug 2026 12:00:55 +0300 Subject: [PATCH 04/17] supersede bg refresh with refresh click --- .../Editor/Notebook/NotebookToolbar.tsx | 1 + .../chartRefresh/chartRefreshEngine.test.ts | 60 +++++++++++++++++++ .../chartRefresh/chartRefreshEngine.ts | 18 +++++- 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/scenes/Editor/Notebook/NotebookToolbar.tsx b/src/scenes/Editor/Notebook/NotebookToolbar.tsx index 81f6f91d4..2e2d80b45 100644 --- a/src/scenes/Editor/Notebook/NotebookToolbar.tsx +++ b/src/scenes/Editor/Notebook/NotebookToolbar.tsx @@ -60,6 +60,7 @@ const TitleContainer = styled(Box).attrs({ align: "center", gap: "0.5rem" })` const Name = styled.span` min-width: 0; + max-width: 40rem; font-size: 1.6rem; font-weight: 600; color: ${color("foreground")}; diff --git a/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.test.ts b/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.test.ts index f64b1062a..d6ea421be 100644 --- a/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.test.ts +++ b/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.test.ts @@ -1803,6 +1803,66 @@ describe("ChartRefreshEngine", () => { expect(cellResults.get("c1")).toBeDefined() }) + it("supersedes a background poll fetch instead of silently skipping the click", async () => { + // Given a visible 5s cell whose poll tick is mid-flight on a slow query + syncOnScreen([drawCell("c1", "select 1", "5s")]) + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + let release!: () => void + deps.executeSingle.mockImplementationOnce( + (sql: string) => + new Promise((res) => { + release = () => res(dqlResult(sql)) + }), + ) + await vi.advanceTimersByTimeAsync(5000) + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + + // When the user clicks refresh-all during that fetch + engine.refreshAll() + await flushAsync() + + // Then the pre-click fetch is superseded by a fresh one immediately + expect(deps.executeSingle).toHaveBeenCalledTimes(3) + + // And the aborted straggler lands without side effects + release() + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(3) + const state = engine.getState("c1") + expect(state?.settledKey).toBe(state?.queriesKey) + }) + + it("flags a hidden cell whose last fetch is still in flight, so reveal redeems the click", async () => { + // Given a polling cell that hid while its poll fetch was in flight + syncOnScreen([drawCell("c1", "select 1", "1s")]) + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + let release!: () => void + deps.executeSingle.mockImplementationOnce( + (sql: string) => + new Promise((res) => { + release = () => res({ ...dqlResult(sql), dataset: [[3, 4]] }) + }), + ) + await vi.advanceTimersByTimeAsync(1000) + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + engine.setVisible("c1", false) + + // When the user clicks refresh-all and the straggler lands a fresh + // frame while hidden + engine.refreshAll() + release() + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + + // Then the reveal redeems the click with a real fetch — the just-landed + // frame would otherwise count as fresh and settle silently + engine.setVisible("c1", true) + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(3) + }) + it("a flagged cell cleared to empty SQL clears its data on reveal instead of fetching", async () => { // Given a settled cell that hid, got flagged, and lost its SQL syncOnScreen([drawCell("c1", "select 1", false)]) diff --git a/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.ts b/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.ts index aa97bdf3e..be0cc9739 100644 --- a/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.ts +++ b/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.ts @@ -151,6 +151,7 @@ type Entry = { autoRefresh: AutoRefresh visible: boolean pendingManualRefresh: boolean + manualRefreshInFlight: boolean ensureAttempted: boolean lastFetchedAt: number state: ChartFetchState @@ -282,9 +283,14 @@ export class ChartRefreshEngine { refreshAll() { for (const entry of this.entries.values()) { - if (entry.inFlight) continue - if (entry.visible && !this.documentHidden) this.forceRefresh(entry) - else entry.pendingManualRefresh = true + if (!entry.visible || this.documentHidden) { + entry.pendingManualRefresh = true + continue + } + // A repeat click must not abort the fetch the previous click started; a + // background poll fetch queried pre-click state, so supersede it. + if (entry.inFlight && entry.manualRefreshInFlight) continue + this.forceRefresh(entry) } } @@ -295,6 +301,9 @@ export class ChartRefreshEngine { } entry.pendingManualRefresh = false this.promotePendingSql(entry) + entry.manualRefreshInFlight = true + entry.inFlight?.abort() + entry.inFlight = null if (this.shouldPoll(entry)) { entry.lastFetchedAt = 0 entry.poll?.abort() @@ -370,6 +379,7 @@ export class ChartRefreshEngine { state: pendingChartFetchState(cell.value), visible: this.visibilityByCell.get(cell.id) ?? false, pendingManualRefresh: false, + manualRefreshInFlight: false, ensureAttempted: false, lastFetchedAt: 0, classifyCache: new Map(), @@ -436,6 +446,7 @@ export class ChartRefreshEngine { private applySqlState(entry: Entry, sql: string) { entry.inFlight?.abort() entry.inFlight = null + entry.manualRefreshInFlight = false this.stopResultLoadWait(entry) entry.sql = sql entry.classifyCache = new Map() @@ -719,6 +730,7 @@ export class ChartRefreshEngine { // must not flip `fetching` off while its replacement is in flight. if (entry.inFlight === ac) { entry.inFlight = null + entry.manualRefreshInFlight = false entry.lastFetchedAt = Date.now() this.setState(entry, { fetching: false }) } From 5121dd99f6e73c457f13c07367c7568251644949 Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 4 Aug 2026 14:57:19 +0300 Subject: [PATCH 05/17] fix override count calculation, manual refresh chart lost in jitter window --- e2e/questdb | 2 +- .../Notebook/NotebookRefreshControl.tsx | 7 ++- .../chartRefresh/chartRefreshEngine.test.ts | 58 +++++++++++++++++++ .../chartRefresh/chartRefreshEngine.ts | 11 +++- .../Editor/Notebook/notebookUtils.test.ts | 14 +++++ src/scenes/Editor/Notebook/notebookUtils.ts | 6 ++ 6 files changed, 94 insertions(+), 4 deletions(-) diff --git a/e2e/questdb b/e2e/questdb index 8fb0a75c1..c34600e79 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 8fb0a75c136124ddcd24756d21fc756f8e0b408a +Subproject commit c34600e7974a7087a5f18996a267981682b4e27d diff --git a/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx b/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx index 8037644ad..5b13503ef 100644 --- a/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx +++ b/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx @@ -9,7 +9,10 @@ import { useNotebookBufferId, useNotebookState, } from "./NotebookProvider" -import { autoRefreshLabel, countAutoRefreshOverrides } from "./notebookUtils" +import { + autoRefreshLabel, + countActiveAutoRefreshOverrides, +} from "./notebookUtils" import type { AutoRefresh } from "../../../store/notebook" import { IntervalLabel, @@ -38,7 +41,7 @@ export const NotebookRefreshControl: React.FC = () => { const bufferId = useNotebookBufferId() const defaultValue = settings.autoRefreshDefault ?? true const defaultLabel = autoRefreshLabel(defaultValue) - const overrideCount = countAutoRefreshOverrides(cells) + const overrideCount = countActiveAutoRefreshOverrides(cells) const drawCellCount = cells.filter((cell) => cell.mode === "draw").length const intervalAriaLabel = overrideCount > 0 diff --git a/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.test.ts b/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.test.ts index d6ea421be..542a912b5 100644 --- a/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.test.ts +++ b/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.test.ts @@ -1736,6 +1736,64 @@ describe("ChartRefreshEngine", () => { random.mockRestore() }) + it("a refresh-all click survives the cell hiding during the start jitter", async () => { + // Given a jittered engine (deterministic 150ms) with a settled 30s cell + const random = vi.spyOn(Math, "random").mockReturnValue(0.5) + engine.destroy() + engine = new ChartRefreshEngine( + BUFFER_ID, + () => deps as ChartRefreshDeps, + { initialFetchJitterMs: 300 }, + ) + engine.attach() + engine.setVisible("c1", true) + engine.sync([drawCell("c1", "select 1", "30s")]) + await vi.advanceTimersByTimeAsync(150) + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + + // When the user clicks refresh-all and the cell hides inside the jitter + engine.refreshAll() + await vi.advanceTimersByTimeAsync(50) + engine.setVisible("c1", false) + await vi.advanceTimersByTimeAsync(5000) + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + + // Then the reveal redeems the click instead of settling from stale data + engine.setVisible("c1", true) + await vi.advanceTimersByTimeAsync(150) + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + random.mockRestore() + }) + + it("a refresh-all click survives a tab switch during the start jitter", async () => { + // Given a jittered engine (deterministic 150ms) with a settled 30s cell + const random = vi.spyOn(Math, "random").mockReturnValue(0.5) + engine.destroy() + engine = new ChartRefreshEngine( + BUFFER_ID, + () => deps as ChartRefreshDeps, + { initialFetchJitterMs: 300 }, + ) + engine.attach() + engine.setVisible("c1", true) + engine.sync([drawCell("c1", "select 1", "30s")]) + await vi.advanceTimersByTimeAsync(150) + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + + // When the user clicks refresh-all and switches tabs inside the jitter + engine.refreshAll() + await vi.advanceTimersByTimeAsync(50) + setDocumentHidden(true) + await vi.advanceTimersByTimeAsync(5000) + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + + // Then the return to the tab redeems the click with a real fetch + setDocumentHidden(false) + await vi.advanceTimersByTimeAsync(150) + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + random.mockRestore() + }) + it("the pending refresh dies when the cell leaves draw mode", async () => { // Given a settled Off cell that hid and got flagged syncOnScreen([drawCell("c1", "select 1", false)]) diff --git a/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.ts b/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.ts index be0cc9739..cef268949 100644 --- a/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.ts +++ b/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.ts @@ -569,7 +569,15 @@ export class ChartRefreshEngine { entry.poll?.abort() entry.poll = null entry.pollKey = key - if (!enabled) return + if (!enabled) { + // A manual refresh still waiting out the start jitter dies with the + // poll — hand it back to pending so resume() redeems the click. + if (entry.manualRefreshInFlight && !entry.inFlight) { + entry.manualRefreshInFlight = false + entry.pendingManualRefresh = true + } + return + } const abort = new AbortController() entry.poll = abort void this.runPollLoop(entry, abort) @@ -602,6 +610,7 @@ export class ChartRefreshEngine { this.stopResultLoadWait(entry) const { queries, queriesKey } = entry.state if (queries.length === 0) { + entry.manualRefreshInFlight = false this.setState(entry, { fetching: false, settledKey: queriesKey, diff --git a/src/scenes/Editor/Notebook/notebookUtils.test.ts b/src/scenes/Editor/Notebook/notebookUtils.test.ts index 9242140dd..2a2e20e4c 100644 --- a/src/scenes/Editor/Notebook/notebookUtils.test.ts +++ b/src/scenes/Editor/Notebook/notebookUtils.test.ts @@ -20,6 +20,7 @@ import { cellToolbarTier, clearCellAutoRefresh, cloneNotebookViewState, + countActiveAutoRefreshOverrides, countAutoRefreshOverrides, isAutoRefreshOverride, resolveAutoRefresh, @@ -2663,6 +2664,19 @@ describe("auto-refresh inheritance helpers", () => { expect(isAutoRefreshOverride(cells[2])).toBe(false) }) + it("countActiveAutoRefreshOverrides ignores dormant run-cell keys — only draw overrides show in the toolbar", () => { + // Given a draw override, a dormant run-mode override, and an inheriting cell + const cells: NotebookCell[] = [ + { ...cell("a", "SELECT 1"), mode: "draw", autoRefresh: "5s" }, + { ...cell("b", "SELECT 2"), mode: "run", autoRefresh: false }, + cell("c", "SELECT 3"), + ] + // Then only the draw override counts toward the displayed total + expect(countActiveAutoRefreshOverrides(cells)).toBe(1) + // And a notebook with only dormant keys shows no override at all + expect(countActiveAutoRefreshOverrides([cells[1], cells[2]])).toBe(0) + }) + it("clearCellAutoRefresh deletes the key so a later draw switch cannot resurrect it", () => { // Given a run cell carrying a dormant override const dormant: NotebookCell = { diff --git a/src/scenes/Editor/Notebook/notebookUtils.ts b/src/scenes/Editor/Notebook/notebookUtils.ts index 3efb0abe8..30fad4680 100644 --- a/src/scenes/Editor/Notebook/notebookUtils.ts +++ b/src/scenes/Editor/Notebook/notebookUtils.ts @@ -66,6 +66,12 @@ export const isAutoRefreshOverride = ( export const countAutoRefreshOverrides = (cells: NotebookCell[]): number => cells.filter(isAutoRefreshOverride).length +export const countActiveAutoRefreshOverrides = ( + cells: NotebookCell[], +): number => + cells.filter((cell) => cell.mode === "draw" && isAutoRefreshOverride(cell)) + .length + export const clearCellAutoRefresh = (cell: NotebookCell): NotebookCell => { if (!isAutoRefreshOverride(cell)) return cell const { autoRefresh: _, ...rest } = cell From 0e34b144a578c7c4d04ced3df9c977f500201153 Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 4 Aug 2026 15:58:47 +0300 Subject: [PATCH 06/17] bump expected bridge version --- src/utils/mcp/protocolVersion.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/mcp/protocolVersion.ts b/src/utils/mcp/protocolVersion.ts index 1bccc4049..3f82619e6 100644 --- a/src/utils/mcp/protocolVersion.ts +++ b/src/utils/mcp/protocolVersion.ts @@ -2,7 +2,7 @@ // WS frame as `v` and echoed in `hello.expectedBridgeVersion`. Bridge // compares same-major (connect + warning) vs different-major (close 4004). // Bump in lockstep with verified bridge releases. -export const EXPECTED_BRIDGE_VERSION = "0.2.0" +export const EXPECTED_BRIDGE_VERSION = "0.3.0" export type BridgeVersionMismatch = "major" | "minor" From 1249e9d06e598ee05de5c72331596fec42caa97d Mon Sep 17 00:00:00 2001 From: emrberk Date: Wed, 5 Aug 2026 16:01:02 +0300 Subject: [PATCH 07/17] update shared-definitions.json --- src/consts/shared-definitions.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/consts/shared-definitions.json b/src/consts/shared-definitions.json index 2c2383ea3..48d0b046a 100644 --- a/src/consts/shared-definitions.json +++ b/src/consts/shared-definitions.json @@ -470,7 +470,7 @@ "surfaces": ["ai", "mcp"], "mutatesNotebook": true, "createsNotebook": false, - "description": "Position a single cell in grid mode. x/y/w/h are integers in the 12-column react-grid-layout grid (w ≤ 12).", + "description": "Position a single cell in grid mode. x/y/w/h are integers in the react-grid-layout grid. The 12-column limit applies to WIDTH only (w ≤ 12); h is NOT in column units — the rendered cell box is h*10 + (h-1)*20 px (10px rows with 20px gaps BETWEEN rows; do NOT estimate with h*30), and a fixed 44px cell header inside it leaves (h*30 - 64)px of content. Chart cells pad the plot a further ~40px top and ~56px bottom (~86px when the zoom slider shows) inside that content area. EXAMPLES: markdown h:3 -> 3*10 + 2*20 = 70px box, 70 - 44 = 26px of text (the minimum strip); markdown h:5 -> 130px box, 86px of text (a comfortable title); chart h:10 -> 280px box, 236px content, ~140px of plot after padding (~110px with the zoom slider).", "inputSchema": { "type": "object", "additionalProperties": false, @@ -957,7 +957,7 @@ "grid": { "type": ["object", "null"], "additionalProperties": false, - "description": "Grid position when layout_mode='grid'. x/y/w/h in 12-column units (w ≤ 12).", + "description": "Grid position when layout_mode='grid'. The 12 columns apply to width only (w ≤ 12). Rendered cell box is h*10 + (h-1)*20 px (do NOT estimate with h*30); a fixed 44px header leaves (h*30 - 64)px of content; chart plots pad a further ~40px top and ~56-86px bottom. EXAMPLES: markdown h:3 -> 70px box / 26px text (the minimum); markdown h:5 -> 130px / 86px text (a title); chart h:10 -> 280px / ~140px of plot.", "properties": { "x": { "type": "integer" From bead9e93a70b944204e65939b723d3f31f471c4b Mon Sep 17 00:00:00 2001 From: emrberk Date: Thu, 6 Aug 2026 13:37:46 +0300 Subject: [PATCH 08/17] add notebook tab open event --- src/components/NotebookOnboardingModal/index.tsx | 3 +++ src/modules/ConsoleEventTracker/events.ts | 1 + src/scenes/Editor/Monaco/tabs.tsx | 3 +++ 3 files changed, 7 insertions(+) diff --git a/src/components/NotebookOnboardingModal/index.tsx b/src/components/NotebookOnboardingModal/index.tsx index f41715d77..1e8ca3346 100644 --- a/src/components/NotebookOnboardingModal/index.tsx +++ b/src/components/NotebookOnboardingModal/index.tsx @@ -159,6 +159,9 @@ export const NotebookOnboardingModal = () => { source: "onboarding_modal", step, }) + void trackEvent(ConsoleEvent.NOTEBOOK_TAB_OPEN, { + source: "onboarding_modal", + }) } }) setOpen(false) diff --git a/src/modules/ConsoleEventTracker/events.ts b/src/modules/ConsoleEventTracker/events.ts index d381dab30..90f6425bd 100644 --- a/src/modules/ConsoleEventTracker/events.ts +++ b/src/modules/ConsoleEventTracker/events.ts @@ -104,6 +104,7 @@ export enum ConsoleEvent { NEWS_OPEN = "news.open", NOTEBOOK_CREATE = "notebook.create", + NOTEBOOK_TAB_OPEN = "notebook.tab_open", NOTEBOOK_DUPLICATE = "notebook.duplicate", NOTEBOOK_BUILD_WITH_AI = "notebook.build_with_ai", NOTEBOOK_LOAD_RETRY = "notebook.load_retry", diff --git a/src/scenes/Editor/Monaco/tabs.tsx b/src/scenes/Editor/Monaco/tabs.tsx index 7295d58b1..27655b2ad 100644 --- a/src/scenes/Editor/Monaco/tabs.tsx +++ b/src/scenes/Editor/Monaco/tabs.tsx @@ -716,6 +716,9 @@ export const Tabs = () => { void trackEvent(ConsoleEvent.NOTEBOOK_CREATE, { source: "tab_menu", }) + void trackEvent(ConsoleEvent.NOTEBOOK_TAB_OPEN, { + source: "tab_menu", + }) } }) }} From 979e88d23267276bf32fad85468c6b110aeca84b Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 11 Aug 2026 14:47:05 +0300 Subject: [PATCH 09/17] extend auto-refresh to grid cells with a uniform Off default - rename chartRefresh to cellRefresh; grids refresh per statement (slot errors, per-tab swap tokens, throttled whole-frame snapshots) - resolution is cell value ?? notebook default ?? Off for every view; cells entering draw mode are stamped with an explicit Auto so charts stay born-polling (mode switch, apply, and load-time migration paths) - legacy migration stamps implicit charts instead of synthesizing a notebook default - set_notebook_autorefresh gains reset_cell_overrides for an atomic set-default-and-reset; per-statement rerun persists surviving sibling refresh errors with the frame Co-Authored-By: Claude Fable 5 --- src/consts/shared-definitions.json | 25 +- src/providers/AIStatusProvider/index.tsx | 2 + .../Editor/Notebook/DrawCanvas/index.tsx | 10 +- .../Editor/Notebook/NotebookProvider.tsx | 107 +- .../Notebook/NotebookRefreshControl.tsx | 68 +- .../CellRefreshContext.tsx} | 30 +- .../cellRefreshEngine.test.ts} | 1105 +++++++++++- .../Notebook/cellRefresh/cellRefreshEngine.ts | 1557 +++++++++++++++++ .../cellVirtualization/GridShimmer.tsx | 40 +- .../Notebook/cells/CellBottomContent.tsx | 73 +- .../Notebook/cells/CellRefreshButton.tsx | 112 +- .../Editor/Notebook/cells/CellToolbar.tsx | 11 +- .../Notebook/cells/useCellRunActions.ts | 31 +- .../Editor/Notebook/cells/useChartLoading.ts | 10 +- .../chartRefresh/chartRefreshEngine.ts | 856 --------- src/scenes/Editor/Notebook/index.tsx | 4 +- .../Editor/Notebook/notebookUtils.test.ts | 388 +++- src/scenes/Editor/Notebook/notebookUtils.ts | 273 ++- .../result-table/InlineResultTable.tsx | 50 +- .../Notebook/result-table/ResultGridPanel.tsx | 16 +- .../result-table/StatusNotification.tsx | 100 +- .../Editor/Notebook/result-table/TabBar.tsx | 59 +- .../resultGridViewportStore.test.ts | 36 +- .../result-table/resultGridViewportStore.ts | 31 +- .../result-table/statementSlotView.test.ts | 95 + .../result-table/statementSlotView.ts | 37 + .../cellResultHydration.test.ts | 224 ++- .../resultHydration/cellResultHydration.ts | 127 +- .../Editor/Notebook/useCellExecution.ts | 458 ++++- .../Editor/Notebook/useNotebookPersistence.ts | 9 + src/store/notebook.test.ts | 167 +- src/store/notebook.ts | 47 +- src/store/notebookResults.test.ts | 10 +- src/store/notebookResults.ts | 16 +- src/utils/ai/notebookSnapshot.test.ts | 8 +- src/utils/ai/notebookSnapshot.ts | 78 +- src/utils/ai/runStatus.test.ts | 25 +- src/utils/ai/runStatus.ts | 18 +- src/utils/ai/shared.notebookTools.test.ts | 331 +++- src/utils/notebooks/notebookAIBridge.test.ts | 2 + .../notebooks/notebookController/index.ts | 1 + .../notebookController/notebookController.ts | 43 +- .../notebookTransitions.test.ts | 129 +- .../notebookController/notebookTransitions.ts | 29 +- .../notebooks/notebookDexieController.test.ts | 128 +- src/utils/notebooks/notebookDexieView.ts | 6 +- src/utils/notebooks/notebookHeadlessRun.ts | 146 +- src/utils/questdb/requestLimiter.test.ts | 86 + src/utils/questdb/requestLimiter.ts | 61 + src/utils/tools/applyNotebookState.ts | 32 +- src/utils/tools/dispatch.ts | 111 +- src/utils/tools/permissions.test.ts | 304 +++- src/utils/tools/permissions.ts | 184 +- 53 files changed, 6312 insertions(+), 1594 deletions(-) rename src/scenes/Editor/Notebook/{chartRefresh/ChartRefreshContext.tsx => cellRefresh/CellRefreshContext.tsx} (60%) rename src/scenes/Editor/Notebook/{chartRefresh/chartRefreshEngine.test.ts => cellRefresh/cellRefreshEngine.test.ts} (65%) create mode 100644 src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts delete mode 100644 src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.ts create mode 100644 src/scenes/Editor/Notebook/result-table/statementSlotView.test.ts create mode 100644 src/scenes/Editor/Notebook/result-table/statementSlotView.ts create mode 100644 src/utils/questdb/requestLimiter.test.ts create mode 100644 src/utils/questdb/requestLimiter.ts diff --git a/src/consts/shared-definitions.json b/src/consts/shared-definitions.json index 48d0b046a..a0c241282 100644 --- a/src/consts/shared-definitions.json +++ b/src/consts/shared-definitions.json @@ -222,7 +222,7 @@ "surfaces": ["ai", "mcp"], "mutatesNotebook": false, "createsNotebook": false, - "description": "List the cells in a notebook. Returns id, type, short preview (≤120 chars), position, mode, and last-run status. No cell data values.", + "description": "List the cells in a notebook. Returns id, type, short preview (≤120 chars), position, mode, and last-run status. Three fields are LIVE-ONLY — present only while the notebook is open in the console, so absence never means \"not refreshing\" or \"not blocked\": `refreshing: true` while a refresh is in flight (the visible rows are still the previous round's), `last_refresh_error` when the last round left a failure, and `auto_refresh_blocked: \"contains_write\"` on cells auto-refresh will not run. `last_run_status` is unrelated to these: it stays the outcome of the last completed RUN, and a refresh never changes it. No cell data values.", "inputSchema": { "type": "object", "additionalProperties": false, @@ -241,7 +241,7 @@ "surfaces": ["ai", "mcp"], "mutatesNotebook": false, "createsNotebook": false, - "description": "Get full details of a cell (value, kind via `type`, UI flags, chart config, last-run status + trimmed error). `type:\"markdown\"` marks a prose cell whose `value` is markdown source; SQL cells omit `type`. Never includes query result data. By default the value is capped at 4 KB; a capped response carries `truncated: true` and `full_length`. A truncated value is NOT the cell's real content — NEVER write it back (update_cell / apply_notebook_state value); re-read with get_full_content: true first, or keep the cell with preserve_value: true.", + "description": "Get full details of a cell (value, kind via `type`, UI flags, chart config, last-run status + trimmed error). Three fields are LIVE-ONLY — present only while the notebook is open in the console, so absence never means \"not refreshing\" or \"not blocked\": `refreshing: true` while a refresh is in flight (the visible rows are still the previous round's), `last_refresh_error` when the last round left a failure, and `auto_refresh_blocked: \"contains_write\"` on cells auto-refresh will not run. `last_run_status` is unrelated to these: it stays the outcome of the last completed RUN, and a refresh never changes it. `type:\"markdown\"` marks a prose cell whose `value` is markdown source; SQL cells omit `type`. Never includes query result data. By default the value is capped at 4 KB; a capped response carries `truncated: true` and `full_length`. A truncated value is NOT the cell's real content — NEVER write it back (update_cell / apply_notebook_state value); re-read with get_full_content: true first, or keep the cell with preserve_value: true.", "inputSchema": { "type": "object", "additionalProperties": false, @@ -266,7 +266,7 @@ "surfaces": ["ai", "mcp"], "mutatesNotebook": false, "createsNotebook": false, - "description": "Full structural snapshot of a notebook (layout, cells with previews, kind via `type`, last-run statuses). `type:\"markdown\"` marks a prose cell; SQL cells omit `type`. No cell data values; no columns/rows/count. Previews are capped at 120 chars — cells cut carry `preview_truncated: true` + `full_length`; a preview is never a cell's real content to write back.", + "description": "Full structural snapshot of a notebook (layout, cells with previews, kind via `type`, last-run statuses). Three fields are LIVE-ONLY — present only while the notebook is open in the console, so absence never means \"not refreshing\" or \"not blocked\": `refreshing: true` while a refresh is in flight (the visible rows are still the previous round's), `last_refresh_error` when the last round left a failure, and `auto_refresh_blocked: \"contains_write\"` on cells auto-refresh will not run. `last_run_status` is unrelated to these: it stays the outcome of the last completed RUN, and a refresh never changes it. `auto_refresh_default` is omitted when the notebook has no configured default. `type:\"markdown\"` marks a prose cell; SQL cells omit `type`. No cell data values; no columns/rows/count. Previews are capped at 120 chars — cells cut carry `preview_truncated: true` + `full_length`; a preview is never a cell's real content to write back.", "inputSchema": { "type": "object", "additionalProperties": false, @@ -284,7 +284,7 @@ "surfaces": ["ai", "mcp"], "mutatesNotebook": true, "createsNotebook": false, - "description": "Append a cell to the notebook. Returns the new cell id and, if run=true, a per-query status array. You never see query rows or column data. Set type:\"markdown\" to add a prose cell instead of a SQL cell — its `sql` field then carries the markdown source, it is rendered (never executed), and `run` is ignored.", + "description": "Append a cell to the notebook. Returns the new cell id and, if run=true, a per-query status array with the same semantics as run_cell (reads run in parallel; an invalid statement is skipped with its error). Writes are never auto-run: a cell containing DDL/DML comes back `{ ran: false, skipped: true }`. You never see query rows or column data. Set type:\"markdown\" to add a prose cell instead of a SQL cell — its `sql` field then carries the markdown source, it is rendered (never executed), and `run` is ignored.", "inputSchema": { "type": "object", "additionalProperties": false, @@ -319,7 +319,7 @@ "surfaces": ["ai", "mcp"], "mutatesNotebook": true, "createsNotebook": false, - "description": "Replace a cell's value. Overwrites preemptively — cells are auto-saved. Use to fix a broken SQL cell. When editing part of a long cell, base the new value on a non-truncated read (get_cell with get_full_content: true) — never on a preview or a `truncated: true` read.", + "description": "Replace a cell's value. Overwrites preemptively — cells are auto-saved. Results carry over by content: a statement whose text is unchanged keeps its result and its refresh state, an edited or added one starts empty, and a rewrite that leaves no statement unchanged clears the cell's results. Use to fix a broken SQL cell. When editing part of a long cell, base the new value on a non-truncated read (get_cell with get_full_content: true) — never on a preview or a `truncated: true` read.", "inputSchema": { "type": "object", "additionalProperties": false, @@ -427,7 +427,7 @@ "surfaces": ["ai", "mcp"], "mutatesNotebook": true, "createsNotebook": false, - "description": "Execute a SQL cell. Each `;`-separated statement runs sequentially; if one fails, the remaining statements are NOT attempted. Returns `{ success, queryCount, results: string[] }`, where each `results` entry is `\"success\"`, `\"cancelled\"`, or `\"ERROR: \"`, in source order. You do NOT see columns, rows, or values — call run_query if you need data. `success` is true only when every statement reached `\"success\"`. This is the ONLY path that executes agent-initiated DDL/DML in a cell (apply_notebook_state and add_cell never auto-run writes) — it requires the 'write' permission and the user's consent. A markdown cell is never executed: the response is `{ ran: false, skipped: true, note: }`.", + "description": "Execute a SQL cell. A cell whose statements are all reads runs them in PARALLEL: one failure skips nothing, and a statement the server rejects at validation is skipped with its validation error as that statement's result. A cell containing any DDL/DML runs sequentially instead, and a failure stops the remaining statements. Returns `{ success, queryCount, results: string[] }`, where each `results` entry is `\"success\"`, `\"cancelled\"`, or `\"ERROR: \"`, in source order. You do NOT see columns, rows, or values — call run_query if you need data. `success` is true only when every statement reached `\"success\"`. This is the ONLY path that executes agent-initiated DDL/DML in a cell (apply_notebook_state and add_cell never auto-run writes) — it requires the 'write' permission and the user's consent. A markdown cell is never executed: the response is `{ ran: false, skipped: true, note: }`.", "inputSchema": { "type": "object", "additionalProperties": false, @@ -649,7 +649,7 @@ "surfaces": ["ai", "mcp"], "mutatesNotebook": true, "createsNotebook": false, - "description": "Set auto-refresh polling for a draw-mode cell's chart, as a per-cell override of the notebook default (set_notebook_autorefresh). `value`: true = adaptive poll (interval auto-tuned to response time), false = no polling, a fixed interval string (\"1s\", \"5s\", \"10s\", \"30s\", \"1m\"), or null to clear the override so the cell inherits the notebook default.", + "description": "Set auto-refresh polling for a cell, as a per-cell override of the notebook default (set_notebook_autorefresh). Applies to both chart (draw-mode) and grid (run-mode) cells. A cell containing DDL/DML never polls: the value is stored, but the engine blocks its ticks and read tools report `auto_refresh_blocked: \"contains_write\"`. Markdown cells are rejected. Nothing polls without a per-cell value or a notebook default; a cell that becomes a chart while the notebook has no default is stamped with an explicit `true` so charts are born polling — that stamp is a normal per-cell value you can read and change. `value`: true = adaptive poll (interval auto-tuned to response time), false = no polling, a fixed interval string (\"1s\", \"5s\", \"10s\", \"30s\", \"1m\"), or null to clear the override so the cell inherits the notebook default.", "inputSchema": { "type": "object", "additionalProperties": false, @@ -681,7 +681,7 @@ "surfaces": ["ai", "mcp"], "mutatesNotebook": true, "createsNotebook": false, - "description": "Set the notebook-level auto-refresh default for draw-mode charts. Cells with no per-cell value inherit it; set_cell_autorefresh sets a per-cell override that wins over it. `value`: true = adaptive poll (interval auto-tuned to response time), false = no polling, or a fixed interval string (\"1s\", \"5s\", \"10s\", \"30s\", \"1m\").", + "description": "Set the notebook-level auto-refresh default for every cell showing a chart or a grid. Cells with no per-cell value inherit it; set_cell_autorefresh sets a per-cell override that wins over it. Cells containing DDL/DML are skipped — auto-refresh never executes a write. Until this is set, nothing polls except chart cells, which get an explicit per-cell `true` stamped when they become charts. `value`: true = adaptive poll (interval auto-tuned to response time), false = no polling, or a fixed interval string (\"1s\", \"5s\", \"10s\", \"30s\", \"1m\"). `reset_cell_overrides: true` additionally deletes every per-cell override in the same atomic call, so ALL cells follow the new default — the equivalent of the console's \"Reset cell overrides\" action. Nothing re-runs; false or null leaves per-cell overrides in place and they keep winning over the default.", "inputSchema": { "type": "object", "additionalProperties": false, @@ -699,9 +699,12 @@ "enum": ["1s", "5s", "10s", "30s", "1m"] } ] + }, + "reset_cell_overrides": { + "type": ["boolean", "null"] } }, - "required": ["buffer_id", "value"] + "required": ["buffer_id", "value", "reset_cell_overrides"] } }, { @@ -760,7 +763,7 @@ "surfaces": ["ai", "mcp"], "mutatesNotebook": true, "createsNotebook": false, - "description": "Bulk-apply the entire desired state of a notebook in one atomic call. Use this for bulk edits spanning multiple cells or creating a notebook from scratch. Use update_cell or set_cell_* for small operations. Use INSTEAD OF chained add_cell + update_cell + set_cell_mode + set_cell_chart_config only when composing a multi-cell layout from scratch, changing many cells at once, or restructuring an existing notebook. The cells array is the COMPLETE desired list: cells in the current notebook whose id is missing from your request are DELETED. For new cells, omit `id` and one will be generated. Each cell carries exactly one of `value` (full verbatim SQL) or `preserve_value: true` (keep the existing cell's SQL, results, and run history unchanged) — prefer preserve_value for every cell whose SQL you are not changing, and NEVER send a value reconstructed from a preview or a truncated get_cell read. Charts in mode='draw' render automatically — do not call run_cell afterwards. Cells with resolved mode='run' (explicit, or omitted: new defaults to 'run', existing preserves) auto-execute after the apply — EXCEPT cells whose statements include DDL/DML (INSERT/UPDATE/CREATE/DROP/...): those are NEVER auto-executed (their `runs` entry gets `skipped: true`), so applying state can never trigger a write's side effects. Take consent from the user, then call run_cell explicitly to execute them. Markdown cells (type:\"markdown\") are rendered prose and are likewise never auto-run. The response includes a `runs: [{cellId, success, queryCount?, results?, error?, skipped?}]` array — `results` is the per-statement status list (`\"success\"` / `\"cancelled\"` / `\"ERROR: \"`); a top-level `error` is set only when the run was refused before any statement executed. Always call get_workspace_state first; the state-freshness gate applies.", + "description": "Bulk-apply the entire desired state of a notebook in one atomic call. Use this for bulk edits spanning multiple cells or creating a notebook from scratch. Use update_cell or set_cell_* for small operations. Use INSTEAD OF chained add_cell + update_cell + set_cell_mode + set_cell_chart_config only when composing a multi-cell layout from scratch, changing many cells at once, or restructuring an existing notebook. The cells array is the COMPLETE desired list: cells in the current notebook whose id is missing from your request are DELETED. For new cells, omit `id` and one will be generated. Each cell carries exactly one of `value` (full verbatim SQL) or `preserve_value: true` (keep the existing cell's SQL, results, and run history unchanged). A changed `value` carries results over by content: a statement whose text is unchanged keeps its result, an edited or added one starts empty, and a rewrite that leaves nothing unchanged clears the cell's results — prefer preserve_value for every cell whose SQL you are not changing, and NEVER send a value reconstructed from a preview or a truncated get_cell read. Charts in mode='draw' render automatically — do not call run_cell afterwards. Cells with resolved mode='run' (explicit, or omitted: new defaults to 'run', existing preserves) auto-execute after the apply — EXCEPT cells whose statements include DDL/DML (INSERT/UPDATE/CREATE/DROP/...): those are NEVER auto-executed (their `runs` entry gets `skipped: true`), so applying state can never trigger a write's side effects. Take consent from the user, then call run_cell explicitly to execute them. Markdown cells (type:\"markdown\") are rendered prose and are likewise never auto-run. Auto-executed read-only cells run their statements in PARALLEL (one failure skips nothing; a statement rejected at validation is skipped with its validation error). Each cell also accepts `auto_refresh` — the same per-cell override set_cell_autorefresh writes. The response includes a `runs: [{cellId, success, queryCount?, results?, error?, skipped?}]` array — `results` is the per-statement status list (`\"success\"` / `\"cancelled\"` / `\"ERROR: \"`); a top-level `error` is set only when the run was refused before any statement executed. Always call get_workspace_state first; the state-freshness gate applies.", "inputSchema": { "type": "object", "additionalProperties": false, @@ -850,7 +853,7 @@ "enum": ["1s", "5s", "10s", "30s", "1m"] } ], - "description": "Auto-refresh override for draw cells: true = adaptive poll, false = off, or a fixed interval string (\"1s\"/\"5s\"/\"10s\"/\"30s\"/\"1m\"). Omitted or null stores NO override — the cell inherits the notebook's auto_refresh_default." + "description": "Per-cell auto-refresh value: true = adaptive poll, false = off, or a fixed interval string (\"1s\"/\"5s\"/\"10s\"/\"30s\"/\"1m\"). Omitted or null stores NO override — the cell inherits the notebook's auto_refresh_default; a mode='draw' cell with no override and no default is stamped `true` after the apply (charts are born polling)." }, "is_view_maximized": { "type": ["boolean", "null"], diff --git a/src/providers/AIStatusProvider/index.tsx b/src/providers/AIStatusProvider/index.tsx index 6ca42ad66..20a53fac3 100644 --- a/src/providers/AIStatusProvider/index.tsx +++ b/src/providers/AIStatusProvider/index.tsx @@ -62,6 +62,8 @@ export enum AIOperationStatus { // Layout = positional/structural; Chart = visualization settings. ConfiguringLayout = "Configuring layout", ConfiguringChart = "Configuring chart", + // Auto-refresh applies to grids as well as charts — neutral wording. + ConfiguringAutoRefresh = "Configuring auto-refresh", InspectingNotebook = "Inspecting notebook", } diff --git a/src/scenes/Editor/Notebook/DrawCanvas/index.tsx b/src/scenes/Editor/Notebook/DrawCanvas/index.tsx index a90fc6074..4f02e4383 100644 --- a/src/scenes/Editor/Notebook/DrawCanvas/index.tsx +++ b/src/scenes/Editor/Notebook/DrawCanvas/index.tsx @@ -14,11 +14,11 @@ import { toast } from "../../../../components/Toast" import { CircleNotchSpinner } from "../../Monaco/icons" import { eventBus } from "../../../../modules/EventBus" import { EventType } from "../../../../modules/EventBus/types" -import { useChartFetchState } from "../chartRefresh/ChartRefreshContext" +import { useCellFetchState } from "../cellRefresh/CellRefreshContext" import { deriveChartLoading, - pendingChartFetchState, -} from "../chartRefresh/chartRefreshEngine" + pendingCellFetchState, +} from "../cellRefresh/cellRefreshEngine" import { useCellResultStatus } from "../resultHydration/CellResultHydrationContext" import { getChartZoom, @@ -85,10 +85,10 @@ export const DrawCanvas: React.FC = ({ const configAtSettingsOpenRef = useRef(undefined) const chartRendererRef = useRef(null) - const fetchState = useChartFetchState(cell.id) + const fetchState = useCellFetchState(cell.id) const resultStatus = useCellResultStatus(cell.id) const state = useMemo( - () => fetchState ?? pendingChartFetchState(cell.value), + () => fetchState ?? pendingCellFetchState(cell.value), [fetchState, cell.value], ) const { queries, queriesKey, settledKey, classifyBlock } = state diff --git a/src/scenes/Editor/Notebook/NotebookProvider.tsx b/src/scenes/Editor/Notebook/NotebookProvider.tsx index 062602ea7..6adc0367c 100644 --- a/src/scenes/Editor/Notebook/NotebookProvider.tsx +++ b/src/scenes/Editor/Notebook/NotebookProvider.tsx @@ -49,7 +49,9 @@ import { generateId, releaseCellResultPatch, snapshotResultsMatchQueries, + statementKeysFor, } from "./notebookUtils" +import type { RunCellGate } from "../../../utils/tools/permissions" import { signalUserEdit } from "../../../utils/notebooks/notebookAIBridge" import { trackEvent } from "../../../modules/ConsoleEventTracker" import { ConsoleEvent } from "../../../modules/ConsoleEventTracker/events" @@ -65,12 +67,14 @@ import { pruneToRecentNotebooks, } from "../../../store/notebookResults" import { removeNotebookCellLayouts } from "./notebookColumnLayoutStore" +import { persistCellSnapshot } from "./persistCellSnapshot" import type { QueryKey } from "../../../store/Query/types" import { createValidateWithGlobals } from "./declareUtils" import { - ChartRefreshProvider, - useChartRefreshEngine, -} from "./chartRefresh/ChartRefreshContext" + CellRefreshProvider, + useCellRefreshEngine, +} from "./cellRefresh/CellRefreshContext" +import type { CellRefreshEngine } from "./cellRefresh/cellRefreshEngine" import { CellVirtualizationProvider, createVirtualizationEngine, @@ -112,17 +116,18 @@ export type NotebookActions = { sql?: string, signal?: AbortSignal, expectFullValue?: boolean, + gate?: RunCellGate, ) => Promise reRunResultAt: (cellId: string, index: number) => Promise cancelCell: (cellId: string) => void cancelQuery: (cellId: string, index: number) => void - setActiveResultIndex: (cellId: string, index: number) => void + setActiveStatement: (cellId: string, statementKey: string) => void setCellMode: (cellId: string, mode: CellMode) => void clearCellResult: (cellId: string) => void setCellChartConfig: (cellId: string, config: ChartConfig) => void setCellRefresh: (cellId: string, value: AutoRefresh | undefined) => void resetAutoRefreshOverrides: () => void - refreshAllCharts: () => void + refreshAllCells: () => { refreshed: number; skippedWrites: number } setCellViewMaximized: (cellId: string, value: boolean) => void setFocusedCell: (cellId: string | null) => void setMaximizedCellId: (cellId: string | null) => void @@ -146,13 +151,13 @@ const NOOP_ACTIONS: NotebookActions = { reRunResultAt: () => Promise.resolve(false), cancelCell: () => undefined, cancelQuery: () => undefined, - setActiveResultIndex: () => undefined, + setActiveStatement: () => undefined, setCellMode: () => undefined, clearCellResult: () => undefined, setCellChartConfig: () => undefined, setCellRefresh: () => undefined, resetAutoRefreshOverrides: () => undefined, - refreshAllCharts: () => undefined, + refreshAllCells: () => ({ refreshed: 0, skippedWrites: 0 }), setCellViewMaximized: () => undefined, setFocusedCell: () => undefined, setMaximizedCellId: () => undefined, @@ -163,6 +168,7 @@ const NOOP_LIVE_ACTIONS: LiveNotebookActions = { ...NOOP_ACTIONS, getSettings: () => ({}), getMaximizedCellId: () => null, + readRefreshState: () => new Map(), flushChartSnapshots: () => Promise.resolve(), applyTransition: (run) => run({ @@ -244,6 +250,8 @@ export const NotebookProvider: React.FC<{ const { executeSingle } = useQueryExecution(settings.variables) + const cellRefreshEngineRef = useRef(null) + const { persistCells, persistImmediately, persistDebounced } = useNotebookPersistence({ bufferId, @@ -252,6 +260,11 @@ export const NotebookProvider: React.FC<{ maximizedCellIdRef, settingsRef, preview, + flushRefreshSnapshots: () => { + void cellRefreshEngineRef.current + ?.flushPendingSnapshots() + .catch(() => undefined) + }, }) const store = useCellsStore({ @@ -269,12 +282,16 @@ export const NotebookProvider: React.FC<{ // Results hydrate per cell on scroll approach and release back to // IndexedDB-only when the cell leaves the retain band. The virtualization - // engine drives both directions; the ref breaks the construction cycle. + // engine drives both directions; the refs break the construction cycles. + // A refreshing cell never releases: its visible frame is the refresh + // round's swap target. const virtualizationEngineRef = useRef(null) const resultHydration = useMemo( () => new CellResultHydrationEngine({ loadSnapshot: (cellId) => loadCellSnapshot(bufferId, cellId), + rewriteSnapshot: (snapshot) => persistCellSnapshot(snapshot), + deleteSnapshot: (cellId) => deleteCellSnapshot(bufferId, cellId), getCell: (cellId) => cellsRef.current.find((c) => c.id === cellId), applyResult: (cellId, result) => { hydrateCells((prev) => @@ -291,7 +308,10 @@ export const NotebookProvider: React.FC<{ ) }, canRelease: (cellId) => - virtualizationEngineRef.current?.canReleaseData(cellId) ?? false, + (virtualizationEngineRef.current?.canReleaseData(cellId) ?? false) && + !(cellRefreshEngineRef.current?.isRefreshing(cellId) ?? false), + seedRefreshErrors: (cellId, errors) => + cellRefreshEngineRef.current?.seedRefreshErrors(cellId, errors), }), [], ) @@ -334,10 +354,16 @@ export const NotebookProvider: React.FC<{ } }, [bufferId, cellsRef]) + const validateWithGlobals = useMemo( + () => createValidateWithGlobals(quest, () => settingsRef.current.variables), + [quest], + ) + const execution = useCellExecution({ bufferId, cellsRef, executeSingle, + validateWithGlobals, updateCellResult: store.updateCellResult, updateCell: store.updateCell, updateCells: store.updateCells, @@ -512,12 +538,7 @@ export const NotebookProvider: React.FC<{ [hydrateCells], ) - const validateWithGlobals = useMemo( - () => createValidateWithGlobals(quest, () => settingsRef.current.variables), - [quest], - ) - - const chartRefreshEngine = useChartRefreshEngine({ + const cellRefreshEngine = useCellRefreshEngine({ bufferId, cells: store.cells, autoRefreshDefault: settings.autoRefreshDefault, @@ -529,15 +550,18 @@ export const NotebookProvider: React.FC<{ store.cellsRef.current.find((c) => c.id === cellId)?.result, isDrawCell: (cellId) => store.cellsRef.current.find((c) => c.id === cellId)?.mode === "draw", + isCellRunning: (cellId) => execution.runningCellIds.has(cellId), resultLoadStatus: (cellId) => resultHydration.statusOf(cellId), subscribeResultLoad: (cellId, listener) => resultHydration.subscribe(cellId, listener), requestResultLoad: (cellId) => resultHydration.request(cellId), noteResultMissing: (cellId) => resultHydration.noteMissing(cellId), + reviveResultLoad: (cellId) => resultHydration.reviveMissing(cellId), onSnapshotPersisted: (cellId, results) => resultHydration.notePersisted(cellId, results), }, }) + cellRefreshEngineRef.current = cellRefreshEngine const cellVirtualizationEngine = createVirtualizationEngine({ bufferId, @@ -547,7 +571,7 @@ export const NotebookProvider: React.FC<{ runningCellIds: execution.runningCellIds, onCellDataNeeded: (cellId) => { resultHydration.request(cellId) - chartRefreshEngine.requestHydrate(cellId) + cellRefreshEngine.requestHydrate(cellId) }, onCellDataReleasable: (cellId) => resultHydration.noteReleasable(cellId), }) @@ -569,8 +593,12 @@ export const NotebookProvider: React.FC<{ sql?: string, signal?: AbortSignal, expectFullValue: boolean = false, + gate?: RunCellGate, ) => { activeCellQueryKeysRef.current.set(cellId, queryKey) + // A manual run always wins: cancel the cell's refresh round up front so + // a settling slot can't race the run's placeholder frame. + cellRefreshEngineRef.current?.noteRunStarted(cellId) try { const outcome = await execution.runCell( @@ -578,7 +606,11 @@ export const NotebookProvider: React.FC<{ sql, signal, expectFullValue, + gate, ) + if (outcome.result) { + cellRefreshEngineRef.current?.noteCellRan(cellId) + } // Size the result-double-view to the result shape, unless the user // locked the bottom height (bottomResized). const cell = store.cellsRef.current.find((c) => c.id === cellId) @@ -595,6 +627,9 @@ export const NotebookProvider: React.FC<{ return outcome } finally { + // Balance noteRunStarted on every outcome — denied, skipped, + // superseded, thrown included — or refresh stays gated forever. + cellRefreshEngineRef.current?.noteRunFinished(cellId) if (activeCellQueryKeysRef.current.get(cellId) === queryKey) { activeCellQueryKeysRef.current.delete(cellId) questExecution.releaseExecution(queryKey, scopeKey) @@ -610,6 +645,7 @@ export const NotebookProvider: React.FC<{ sql?: string, signal?: AbortSignal, expectFullValue: boolean = false, + gate?: RunCellGate, ) => { const runId = ++notebookRunIdRef.current const queryKey = createNotebookQueryKey(bufferId, cellId, runId) @@ -624,6 +660,7 @@ export const NotebookProvider: React.FC<{ sql, signal, expectFullValue, + gate, ).then(resolve) } const request = () => @@ -667,7 +704,7 @@ export const NotebookProvider: React.FC<{ const queries = getQueriesFromText(source?.value ?? "") let snapshotsCopied = 0 if (source) { - await chartRefreshEngine.flushPendingSnapshots().catch(() => undefined) + await cellRefreshEngine.flushPendingSnapshots().catch(() => undefined) try { snapshotsCopied = await copyNotebookSnapshots( bufferId, @@ -707,7 +744,7 @@ export const NotebookProvider: React.FC<{ if (snapshotsCopied === 0) resultHydration.noteMissing(newId) return applied }, - [applyTransition, bufferId, cellsRef, resultHydration, chartRefreshEngine], + [applyTransition, bufferId, cellsRef, resultHydration, cellRefreshEngine], ) const moveCellUp = useCallback( @@ -760,23 +797,45 @@ export const NotebookProvider: React.FC<{ moveCellDown, duplicateCell, runCell, - reRunResultAt: execution.reRunResultAt, + reRunResultAt: (cellId, index) => { + // A per-tab rerun is a manual run: it cancels the refresh round, and + // any COMMIT clears that statement's refresh error — a committed error + // result is newer than the stale refresh failure it replaces. + const engine = cellRefreshEngineRef.current + engine?.noteRunStarted(cellId) + const result = store.cellsRef.current.find((c) => c.id === cellId)?.result + const statementKey = result + ? statementKeysFor(result.results.map((r) => r.query))[index] + : undefined + return execution + .reRunResultAt(cellId, index) + .then((outcome) => { + if (outcome.committed && statementKey !== undefined) { + engine?.noteStatementRan(cellId, statementKey) + } + return outcome.ok + }) + .finally(() => { + cellRefreshEngineRef.current?.noteRunFinished(cellId) + }) + }, cancelCell, cancelQuery: execution.cancelQuery, - setActiveResultIndex: execution.setActiveResultIndex, + setActiveStatement: execution.setActiveStatement, setCellMode, clearCellResult, setCellChartConfig: store.setCellChartConfig, setCellRefresh: store.setCellRefresh, resetAutoRefreshOverrides, - refreshAllCharts: () => chartRefreshEngine.refreshAll(), + refreshAllCells: () => cellRefreshEngine.refreshAll(), setCellViewMaximized, setFocusedCell, setMaximizedCellId, getCellsSnapshot: () => store.cellsRef.current.slice(), getSettings: () => ({ ...settingsRef.current }), getMaximizedCellId: () => maximizedCellIdRef.current, - flushChartSnapshots: () => chartRefreshEngine.flushPendingSnapshots(), + readRefreshState: () => cellRefreshEngine.readRefreshState(), + flushChartSnapshots: () => cellRefreshEngine.flushPendingSnapshots(), applyTransition, } @@ -825,13 +884,13 @@ export const NotebookProvider: React.FC<{ - + {children} - + diff --git a/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx b/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx index 5b13503ef..2ff03ba33 100644 --- a/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx +++ b/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx @@ -1,4 +1,4 @@ -import React from "react" +import React, { useState } from "react" import styled from "styled-components" import { ArrowClockwiseIcon, CaretDownIcon } from "@phosphor-icons/react" import { DropdownMenu, Tooltip } from "../../../components" @@ -9,9 +9,11 @@ import { useNotebookBufferId, useNotebookState, } from "./NotebookProvider" +import { useCellRefresh } from "./cellRefresh/CellRefreshContext" import { autoRefreshLabel, countActiveAutoRefreshOverrides, + resolveCellView, } from "./notebookUtils" import type { AutoRefresh } from "../../../store/notebook" import { @@ -34,15 +36,26 @@ const ResetItemTitle = styled.span` gap: 0.6rem; ` +const MenuHint = styled.div` + max-width: 24rem; + padding: 0.4rem 1rem 0.6rem; + color: ${({ theme }) => theme.color.gray2}; + font-size: ${({ theme }) => theme.fontSize.sm}; +` + export const NotebookRefreshControl: React.FC = () => { const { cells, settings } = useNotebookState() - const { refreshAllCharts, resetAutoRefreshOverrides, updateSettings } = + const { refreshAllCells, resetAutoRefreshOverrides, updateSettings } = useNotebookActions() const bufferId = useNotebookBufferId() - const defaultValue = settings.autoRefreshDefault ?? true - const defaultLabel = autoRefreshLabel(defaultValue) + const cellRefresh = useCellRefresh() + const storedDefault = settings.autoRefreshDefault + const defaultLabel = autoRefreshLabel(storedDefault ?? false) const overrideCount = countActiveAutoRefreshOverrides(cells) - const drawCellCount = cells.filter((cell) => cell.mode === "draw").length + const refreshableCellCount = cells.filter( + (cell) => resolveCellView(cell) !== "none", + ).length + const [writeBlockedCount, setWriteBlockedCount] = useState(0) const intervalAriaLabel = overrideCount > 0 ? `Notebook auto-refresh: ${defaultLabel}, ${overrideCount} ${ @@ -52,17 +65,24 @@ export const NotebookRefreshControl: React.FC = () => { const intervalTooltip = useTriggerTooltip() const handleRefreshAll = () => { + signalUserEdit(bufferId) + const counts = refreshAllCells() void trackEvent(ConsoleEvent.NOTEBOOK_REFRESH_ALL, { - chartCount: drawCellCount, + refreshedCount: counts.refreshed, + skippedWriteCount: counts.skippedWrites, }) - signalUserEdit(bufferId) - refreshAllCharts() + } + + const handleMenuOpenChange = (open: boolean) => { + intervalTooltip.onMenuOpenChange(open) + if (open) setWriteBlockedCount(cellRefresh?.countWriteBlockedGrids() ?? 0) } const handleSelectDefault = (value: AutoRefresh | undefined) => { - if (value === undefined || value === defaultValue) return + if (value === undefined || value === (storedDefault ?? false)) return void trackEvent(ConsoleEvent.NOTEBOOK_AUTOREFRESH_DEFAULT_CHANGE, { - from: defaultLabel, + from: + storedDefault === undefined ? "unset" : autoRefreshLabel(storedDefault), to: autoRefreshLabel(value), }) updateSettings({ autoRefreshDefault: value }) @@ -75,19 +95,19 @@ export const NotebookRefreshControl: React.FC = () => { return ( - + - + { {overrideCount > 0 && ( @@ -128,6 +148,24 @@ export const NotebookRefreshControl: React.FC = () => { )} + {storedDefault === undefined && ( + <> + + + Charts poll on Auto; grids stay off until you set a default. + + + )} + {writeBlockedCount > 0 && ( + <> + + + {writeBlockedCount === 1 + ? "1 cell contains DDL/DML and is excluded from auto-refresh." + : `${writeBlockedCount} cells contain DDL/DML and are excluded from auto-refresh.`} + + + )} diff --git a/src/scenes/Editor/Notebook/chartRefresh/ChartRefreshContext.tsx b/src/scenes/Editor/Notebook/cellRefresh/CellRefreshContext.tsx similarity index 60% rename from src/scenes/Editor/Notebook/chartRefresh/ChartRefreshContext.tsx rename to src/scenes/Editor/Notebook/cellRefresh/CellRefreshContext.tsx index 5d27c3060..dc3221c6f 100644 --- a/src/scenes/Editor/Notebook/chartRefresh/ChartRefreshContext.tsx +++ b/src/scenes/Editor/Notebook/cellRefresh/CellRefreshContext.tsx @@ -8,27 +8,27 @@ import { } from "react" import type { AutoRefresh, NotebookCell } from "../../../../store/notebook" import { - ChartRefreshEngine, - type ChartFetchState, - type ChartRefreshDeps, -} from "./chartRefreshEngine" + CellRefreshEngine, + type CellFetchState, + type CellRefreshDeps, +} from "./cellRefreshEngine" -const ChartRefreshContext = createContext(null) +const CellRefreshContext = createContext(null) -export const ChartRefreshProvider = ChartRefreshContext.Provider +export const CellRefreshProvider = CellRefreshContext.Provider -export const useChartRefresh = () => useContext(ChartRefreshContext) +export const useCellRefresh = () => useContext(CellRefreshContext) -export const useChartRefreshEngine = (options: { +export const useCellRefreshEngine = (options: { bufferId: number cells: NotebookCell[] autoRefreshDefault?: AutoRefresh - deps: ChartRefreshDeps -}): ChartRefreshEngine => { + deps: CellRefreshDeps +}): CellRefreshEngine => { const { bufferId, cells, autoRefreshDefault, deps } = options const depsRef = useRef(deps) const engine = useMemo( - () => new ChartRefreshEngine(bufferId, () => depsRef.current), + () => new CellRefreshEngine(bufferId, () => depsRef.current), [bufferId], ) @@ -48,11 +48,11 @@ export const useChartRefreshEngine = (options: { return engine } -export const useChartFetchState = ( +export const useCellFetchState = ( cellId: string, -): ChartFetchState | undefined => { - const engine = useContext(ChartRefreshContext) - const [state, setState] = useState(() => +): CellFetchState | undefined => { + const engine = useContext(CellRefreshContext) + const [state, setState] = useState(() => engine?.getState(cellId), ) diff --git a/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.test.ts b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts similarity index 65% rename from src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.test.ts rename to src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts index 542a912b5..615923041 100644 --- a/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.test.ts +++ b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts @@ -12,10 +12,13 @@ import { deleteCellSnapshot } from "../../../../store/notebookResults" import { persistCellSnapshot } from "../persistCellSnapshot" import type { CellResultStatus } from "../resultHydration/cellResultHydration" import { - ChartRefreshEngine, + CellRefreshEngine, deriveChartLoading, - type ChartRefreshDeps, -} from "./chartRefreshEngine" + type CellRefreshDeps, +} from "./cellRefreshEngine" +import { createRequestLimiter } from "../../../../utils/questdb/requestLimiter" +import { clearStatementClassCache } from "../../../../utils/tools/permissions" +import { statementKeysFor } from "../notebookUtils" import { toChartResult } from "../DrawCanvas/drawCanvasUtils" vi.mock("../persistCellSnapshot", () => ({ @@ -86,6 +89,7 @@ const makeDeps = () => { }), getCellResult: vi.fn((cellId: string) => cellResults.get(cellId)), isDrawCell: vi.fn(() => true), + isCellRunning: vi.fn(() => false), resultLoadStatus: vi.fn( (cellId: string): CellResultStatus => loadStatuses.get(cellId) ?? "unrequested", @@ -107,6 +111,11 @@ const makeDeps = () => { noteResultMissing: vi.fn((cellId: string) => { loadStatuses.set(cellId, "missing") }), + reviveResultLoad: vi.fn((cellId: string) => { + if ((loadStatuses.get(cellId) ?? "unrequested") !== "missing") return + loadStatuses.delete(cellId) + deps.requestResultLoad(cellId) + }), onSnapshotPersisted: vi.fn<[string, SingleQueryResult[]], void>(), } const beginLoadOnRequest = () => { @@ -149,11 +158,11 @@ const setDocumentHidden = (hidden: boolean) => { fakeDocument.dispatchEvent(new Event("visibilitychange")) } -describe("ChartRefreshEngine", () => { +describe("CellRefreshEngine", () => { let harness: ReturnType let deps: ReturnType["deps"] let cellResults: ReturnType["cellResults"] - let engine: ChartRefreshEngine + let engine: CellRefreshEngine // Entries start hidden until an observer reports them (no init-load fetch // burst); tests that model on-screen cells report visibility before sync. @@ -167,13 +176,14 @@ describe("ChartRefreshEngine", () => { beforeEach(() => { vi.useFakeTimers() + clearStatementClassCache() fakeDocument.hidden = false ;(globalThis as { document?: unknown }).document = fakeDocument harness = makeDeps() deps = harness.deps cellResults = harness.cellResults // Jitter off: tests assert exact fetch timing. - engine = new ChartRefreshEngine(BUFFER_ID, () => deps as ChartRefreshDeps, { + engine = new CellRefreshEngine(BUFFER_ID, () => deps as CellRefreshDeps, { initialFetchJitterMs: 0, }) engine.attach() @@ -760,9 +770,9 @@ describe("ChartRefreshEngine", () => { it("does not skip the catch-up fetch after a poll aborted during its start jitter", async () => { // Given a jittered engine whose cell holds an old settled frame - const jittered = new ChartRefreshEngine( + const jittered = new CellRefreshEngine( BUFFER_ID, - () => deps as ChartRefreshDeps, + () => deps as CellRefreshDeps, { initialFetchJitterMs: 300 }, ) cellResults.set("c1", dqlCellResult("select 1")) @@ -1259,6 +1269,12 @@ describe("ChartRefreshEngine", () => { fetching: true, settledKey: "select 1", classifyBlock: null, + classifiedKey: null, + slotFetching: new Set(), + slotErrors: new Map(), + cancelledSlots: new Set(), + slotFetchedAt: new Map(), + slotSwappedAt: new Map(), } // Then the recovery fetch after a failed restore shows the spinner @@ -1311,9 +1327,9 @@ describe("ChartRefreshEngine", () => { it("bounds concurrent fetches across cells", async () => { // Given an engine capped at one in-flight fetch and a slow first query engine.destroy() - engine = new ChartRefreshEngine(BUFFER_ID, () => deps as ChartRefreshDeps, { + engine = new CellRefreshEngine(BUFFER_ID, () => deps as CellRefreshDeps, { initialFetchJitterMs: 0, - maxConcurrentFetches: 1, + requestLimiter: createRequestLimiter(1), }) engine.attach() let releaseFirst!: (value: QueryExecResult) => void @@ -1342,7 +1358,7 @@ describe("ChartRefreshEngine", () => { // so the jitter is a deterministic 150ms const random = vi.spyOn(Math, "random").mockReturnValue(0.5) engine.destroy() - engine = new ChartRefreshEngine(BUFFER_ID, () => deps as ChartRefreshDeps, { + engine = new CellRefreshEngine(BUFFER_ID, () => deps as CellRefreshDeps, { initialFetchJitterMs: 300, }) engine.attach() @@ -1597,11 +1613,10 @@ describe("ChartRefreshEngine", () => { it("queues visible refetches through the fetch limiter", async () => { // Given an engine capped at one in-flight fetch with two settled cells engine.destroy() - engine = new ChartRefreshEngine( - BUFFER_ID, - () => deps as ChartRefreshDeps, - { initialFetchJitterMs: 0, maxConcurrentFetches: 1 }, - ) + engine = new CellRefreshEngine(BUFFER_ID, () => deps as CellRefreshDeps, { + initialFetchJitterMs: 0, + requestLimiter: createRequestLimiter(1), + }) engine.attach() syncOnScreen([ drawCell("c1", "select 1", false), @@ -1702,11 +1717,9 @@ describe("ChartRefreshEngine", () => { // polling cell const random = vi.spyOn(Math, "random").mockReturnValue(0.5) engine.destroy() - engine = new ChartRefreshEngine( - BUFFER_ID, - () => deps as ChartRefreshDeps, - { initialFetchJitterMs: 300 }, - ) + engine = new CellRefreshEngine(BUFFER_ID, () => deps as CellRefreshDeps, { + initialFetchJitterMs: 300, + }) engine.attach() engine.setVisible("c1", true) engine.sync([drawCell("c1", "select 1", "1s")]) @@ -1740,11 +1753,9 @@ describe("ChartRefreshEngine", () => { // Given a jittered engine (deterministic 150ms) with a settled 30s cell const random = vi.spyOn(Math, "random").mockReturnValue(0.5) engine.destroy() - engine = new ChartRefreshEngine( - BUFFER_ID, - () => deps as ChartRefreshDeps, - { initialFetchJitterMs: 300 }, - ) + engine = new CellRefreshEngine(BUFFER_ID, () => deps as CellRefreshDeps, { + initialFetchJitterMs: 300, + }) engine.attach() engine.setVisible("c1", true) engine.sync([drawCell("c1", "select 1", "30s")]) @@ -1769,11 +1780,9 @@ describe("ChartRefreshEngine", () => { // Given a jittered engine (deterministic 150ms) with a settled 30s cell const random = vi.spyOn(Math, "random").mockReturnValue(0.5) engine.destroy() - engine = new ChartRefreshEngine( - BUFFER_ID, - () => deps as ChartRefreshDeps, - { initialFetchJitterMs: 300 }, - ) + engine = new CellRefreshEngine(BUFFER_ID, () => deps as CellRefreshDeps, { + initialFetchJitterMs: 300, + }) engine.attach() engine.setVisible("c1", true) engine.sync([drawCell("c1", "select 1", "30s")]) @@ -2032,4 +2041,1038 @@ describe("ChartRefreshEngine", () => { expect(deps.executeSingle).toHaveBeenCalledTimes(2) }) }) + + describe("grid entries", () => { + const gridFrame = (queries: string[]): CellResult => ({ + results: queries.map((q) => ({ + type: "dql" as const, + query: q, + columns: [{ name: "x", type: "INT" }], + dataset: [[1]], + count: 1, + timestamp: 0, + })), + activeResultIndex: 0, + timestamp: 0, + }) + + const gridCell = ( + id: string, + value: string, + queries: string[], + autoRefresh: AutoRefresh | undefined, + ): NotebookCell => { + const result = gridFrame(queries) + cellResults.set(id, result) + return { id, position: 0, value, mode: "run", autoRefresh, result } + } + + const errorValidation = (sql: string, error: string) => ({ + query: sql, + position: 0, + error, + }) + + const validateBySql = (map: Record) => { + deps.validateWithGlobals.mockImplementation((sql: string) => + Promise.resolve(map[sql.trim()] ?? dqlValidation), + ) + } + + const keyOf = (sql: string) => statementKeysFor([sql])[0] + + it("ticks a refreshable grid and swaps each slot's rows in place", async () => { + // Given a two-statement run cell with a visible grid on a 1s interval + const cell = gridCell( + "g1", + "select 1; select 2", + ["select 1", "select 2"], + "1s", + ) + syncOnScreen([cell]) + await flushAsync() + + // Then the round executes both statements in parallel + expect(deps.executeSingle.mock.calls.map(([sql]) => sql)).toEqual([ + "select 1", + "select 2", + ]) + + // And each slot swapped its own rows while the frame kept both tabs + const written = cellResults.get("g1") + expect(written?.results.map((r) => r.query)).toEqual([ + "select 1", + "select 2", + ]) + expect( + written?.results.every( + (r) => r.type === "dql" && r.columns.length === 2, + ), + ).toBe(true) + }) + + it("keeps the frame timestamp across slot settles — sibling tabs never lose their viewport token", async () => { + // Given a two-statement grid whose frame settled at a known time + const cell = gridCell( + "g1", + "select 1; select 2", + ["select 1", "select 2"], + "1s", + ) + syncOnScreen([cell]) + await flushAsync() + + // Then the swapped frame keeps the run's own timestamp — the token a + // sibling tab derives its viewport from must not churn per slot settle + const written = cellResults.get("g1") + expect(written?.results).toHaveLength(2) + expect(written?.timestamp).toBe(0) + }) + + it("skips the commit and holds the swap token on identical rows, while the fetch time advances", async () => { + // Given a polling grid whose first tick swapped fresh rows in + const cell = gridCell("g1", "select 1", ["select 1"], "1s") + syncOnScreen([cell]) + await flushAsync() + expect(deps.setCellResult).toHaveBeenCalledTimes(1) + const state = engine.getState("g1") + const fetchedAfterSwap = state?.slotFetchedAt.get(keyOf("select 1")) + const swappedAfterSwap = state?.slotSwappedAt.get(keyOf("select 1")) + expect(fetchedAfterSwap).toBeDefined() + expect(swappedAfterSwap).toBeDefined() + + // When later ticks return the same rows + await vi.advanceTimersByTimeAsync(6000) + + // Then the frame is never re-written and the swap token holds — a + // re-commit would reset the tab's scroll and focus for identical data — + // while the fetch time keeps advancing for the status line + expect(deps.executeSingle.mock.calls.length).toBeGreaterThan(1) + expect(deps.setCellResult).toHaveBeenCalledTimes(1) + const settled = engine.getState("g1") + expect(settled?.slotSwappedAt.get(keyOf("select 1"))).toBe( + swappedAfterSwap, + ) + expect( + settled?.slotFetchedAt.get(keyOf("select 1")) ?? 0, + ).toBeGreaterThan(fetchedAfterSwap ?? 0) + }) + + it("commits and advances the swap token when a tick returns changed rows", async () => { + // Given a polling grid whose data moves every tick + let tick = 0 + deps.executeSingle.mockImplementation((sql: string) => { + tick += 1 + return Promise.resolve({ + type: "dql" as const, + query: sql, + columns: [{ name: "x", type: "INT" }], + dataset: [[100 + tick]], + count: 1, + }) + }) + const cell = gridCell("g1", "select 1", ["select 1"], "1s") + syncOnScreen([cell]) + await flushAsync() + const writesAfterSwap = deps.setCellResult.mock.calls.length + const swappedAfterSwap = engine + .getState("g1") + ?.slotSwappedAt.get(keyOf("select 1")) + expect(swappedAfterSwap).toBeDefined() + + // When the next tick lands with different rows + await vi.advanceTimersByTimeAsync(6000) + + // Then the slot commits again and its swap token advances + expect(deps.setCellResult.mock.calls.length).toBeGreaterThan( + writesAfterSwap, + ) + expect( + engine.getState("g1")?.slotSwappedAt.get(keyOf("select 1")) ?? 0, + ).toBeGreaterThan(swappedAfterSwap ?? 0) + }) + + it("clears a slot's refresh error without a commit when identical rows confirm recovery", async () => { + // Given a grid whose refresh failed once, old rows still on screen + deps.executeSingle.mockImplementation((sql: string) => + Promise.resolve({ + type: "dql" as const, + query: sql, + columns: [{ name: "x", type: "INT" }], + dataset: [[1]], + count: 1, + }), + ) + deps.executeSingle.mockImplementationOnce((sql: string) => + Promise.resolve({ + type: "error" as const, + query: sql, + columns: [], + dataset: [], + count: 0, + error: "boom", + }), + ) + const cell = gridCell("g1", "select 1", ["select 1"], false) + syncOnScreen([cell]) + await flushAsync() + void engine.refresh("g1") + await flushAsync() + expect(engine.getState("g1")?.slotErrors.get(keyOf("select 1"))).toBe( + "boom", + ) + + // When the next refresh succeeds with the rows the frame already holds + void engine.refresh("g1") + await flushAsync() + + // Then the badge clears and the fetch time records the verifying poll, + // while the frame and its swap token stay untouched + const state = engine.getState("g1") + expect(state?.slotErrors.size).toBe(0) + expect(deps.setCellResult).not.toHaveBeenCalled() + expect(state?.slotFetchedAt.get(keyOf("select 1"))).toBeDefined() + expect(state?.slotSwappedAt.get(keyOf("select 1"))).toBeUndefined() + }) + + it("never registers an editor-only run cell", async () => { + // Given a run cell with no result and no run marker + const cell: NotebookCell = { + id: "g1", + position: 0, + value: "select 1", + mode: "run", + autoRefresh: "1s", + } + + // When the engine syncs it + syncOnScreen([cell]) + await vi.advanceTimersByTimeAsync(5000) + + // Then no entry exists and nothing ever fetches + expect(engine.getState("g1")).toBeUndefined() + expect(deps.executeSingle).not.toHaveBeenCalled() + }) + + it("waits for hydration and never bootstraps a missing frame", async () => { + // Given a released run cell: marker only, snapshot resolves missing + const cell: NotebookCell = { + id: "g1", + position: 0, + value: "select 1", + mode: "run", + autoRefresh: "1s", + lastRunStatus: "success", + } + + // When the engine syncs it and time passes + syncOnScreen([cell]) + await vi.advanceTimersByTimeAsync(5000) + + // Then the entry exists but no fetch ever ran — the first frame always + // comes from the run path + expect(engine.getState("g1")).toBeDefined() + expect(deps.executeSingle).not.toHaveBeenCalled() + }) + + it("keeps a failed slot's old rows and flushes the frame immediately with its error", async () => { + // Given a two-statement grid whose second statement fails on refresh + deps.executeSingle.mockImplementation((sql: string) => + sql === "select 2" + ? Promise.resolve({ + type: "error" as const, + query: sql, + columns: [], + dataset: [], + count: 0, + error: "boom", + }) + : Promise.resolve(dqlResult(sql)), + ) + const cell = gridCell( + "g1", + "select 1; select 2", + ["select 1", "select 2"], + false, + ) + syncOnScreen([cell]) + await flushAsync() + + // When a manual refresh round settles + void engine.refresh("g1") + await flushAsync() + + // Then the failed slot keeps its old rows and carries the error + const state = engine.getState("g1") + expect(state?.slotErrors.get(keyOf("select 2"))).toBe("boom") + const written = cellResults.get("g1") + expect(written?.results[1]).toMatchObject({ + type: "dql", + query: "select 2", + dataset: [[1]], + }) + // And the whole visible frame persisted immediately, error included + expect(persistCellSnapshot).toHaveBeenCalledTimes(1) + expect(persistCellSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + cellId: "g1", + refreshErrors: [{ statementKey: keyOf("select 2"), message: "boom" }], + }), + ) + const persisted = vi.mocked(persistCellSnapshot).mock.calls[0][0] + expect(persisted.results).toHaveLength(2) + }) + + it("persists every manual refresh immediately, even back-to-back inside the throttle window", async () => { + // Given a grid already persisted once, so the throttle window is open + const cell = gridCell("g1", "select 1", ["select 1"], false) + syncOnScreen([cell]) + await flushAsync() + void engine.refresh("g1") + await flushAsync() + expect(persistCellSnapshot).toHaveBeenCalledTimes(1) + + // When the user refreshes again a moment later — well inside the 10s + // snapshot throttle + await vi.advanceTimersByTimeAsync(2000) + void engine.refresh("g1") + await flushAsync() + + // Then the fresh frame is written straight away rather than parked in a + // pending slot that a reload would lose (the pagehide flush is a + // best-effort backstop, never the thing correctness rests on) + expect(persistCellSnapshot).toHaveBeenCalledTimes(2) + }) + + it("throttles automatic ticks — a lost tick frame is regenerated by the next one", async () => { + // Given a grid polling every second + const cell = gridCell("g1", "select 1", ["select 1"], "1s") + syncOnScreen([cell]) + await flushAsync() + const afterFirst = vi.mocked(persistCellSnapshot).mock.calls.length + + // When several ticks land inside one throttle window + await vi.advanceTimersByTimeAsync(4000) + + // Then they do not each hit IndexedDB — the throttle still protects a + // live poller from churning the disk + expect(vi.mocked(persistCellSnapshot).mock.calls.length).toBe(afterFirst) + }) + + it("excludes an invalid statement and refreshes its siblings", async () => { + // Given the second statement fails validation + validateBySql({ "select 2": errorValidation("select 2", "bad column") }) + const cell = gridCell( + "g1", + "select 1; select 2", + ["select 1", "select 2"], + false, + ) + syncOnScreen([cell]) + await flushAsync() + + // When a refresh round settles + void engine.refresh("g1") + await flushAsync() + + // Then only the valid statement executed; the invalid slot carries the + // validation error and keeps its old rows + expect(deps.executeSingle.mock.calls.map(([sql]) => sql)).toEqual([ + "select 1", + ]) + const state = engine.getState("g1") + expect(state?.slotErrors.get(keyOf("select 2"))).toBe("bad column") + expect(cellResults.get("g1")?.results[1]).toMatchObject({ + query: "select 2", + dataset: [[1]], + }) + }) + + it("blocks the whole round when any statement classifies as a write", async () => { + // Given a grid whose second statement is DML + validateBySql({ "select 2": { queryType: "INSERT" } }) + const cell = gridCell( + "g1", + "select 1; select 2", + ["select 1", "select 2"], + false, + ) + syncOnScreen([cell]) + await flushAsync() + + // When a refresh is attempted + void engine.refresh("g1") + await flushAsync() + + // Then nothing executes and the old rows stay + expect(deps.executeSingle).not.toHaveBeenCalled() + expect(engine.getState("g1")?.classifyBlock).toEqual({ + kind: "write", + queryType: "INSERT", + }) + expect(cellResults.get("g1")?.results[0]).toMatchObject({ + dataset: [[1]], + }) + }) + + it("an Off write grid acquires its classification without ever ticking", async () => { + // Given an auto-refresh-off cell containing DML + validateBySql({ "insert into t values (1)": { queryType: "INSERT" } }) + const cell = gridCell( + "g1", + "insert into t values (1)", + ["insert into t values (1)"], + false, + ) + + // When the engine syncs it + syncOnScreen([cell]) + await flushAsync() + + // Then classification ran on creation and nothing executed + expect(engine.getState("g1")?.classifyBlock).toEqual({ + kind: "write", + queryType: "INSERT", + }) + expect(deps.executeSingle).not.toHaveBeenCalled() + }) + + it("cancels one slot after the barrier without failing it", async () => { + // Given the second statement's execution never returns until aborted + deps.executeSingle.mockImplementation( + (sql: string, signal?: AbortSignal) => + sql === "select 2" + ? new Promise((_, reject) => { + signal?.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ) + }) + : Promise.resolve(dqlResult(sql)), + ) + const cell = gridCell( + "g1", + "select 1; select 2", + ["select 1", "select 2"], + false, + ) + syncOnScreen([cell]) + await flushAsync() + void engine.refresh("g1") + await flushAsync() + expect(engine.getState("g1")?.slotFetching.has(keyOf("select 2"))).toBe( + true, + ) + + // When the user cancels that slot + engine.cancelSlot("g1", keyOf("select 2")) + await flushAsync() + + // Then the slot keeps its rows with no error, the sibling settled, and + // the round finished + const state = engine.getState("g1") + expect(state?.fetching).toBe(false) + expect(state?.slotErrors.size).toBe(0) + expect(cellResults.get("g1")?.results[1]).toMatchObject({ + dataset: [[1]], + }) + }) + + it("a pre-barrier cancel drops execution intent while validation completes", async () => { + // Given a validation the round must wait for + const validations = new Map void>() + deps.validateWithGlobals.mockImplementation( + (sql: string) => + new Promise((resolve) => { + validations.set(sql.trim(), resolve) + }), + ) + const cell = gridCell( + "g1", + "select 1; select 2", + ["select 1", "select 2"], + false, + ) + syncOnScreen([cell]) + void engine.refresh("g1") + await flushAsync() + + // When the second slot is cancelled before the barrier settles + engine.cancelSlot("g1", keyOf("select 2")) + validations.get("select 1")?.(dqlValidation) + validations.get("select 2")?.(dqlValidation) + await flushAsync() + + // Then the cancelled slot never executes; its sibling does + expect(deps.executeSingle.mock.calls.map(([sql]) => sql)).toEqual([ + "select 1", + ]) + expect(engine.getState("g1")?.slotErrors.size).toBe(0) + }) + + it("a pre-barrier cancel never unblocks a write cell", async () => { + // Given the cancelled statement classifies as a write at the barrier + const validations = new Map void>() + deps.validateWithGlobals.mockImplementation( + (sql: string) => + new Promise((resolve) => { + validations.set(sql.trim(), resolve) + }), + ) + const cell = gridCell( + "g1", + "select 1; insert into t values (1)", + ["select 1", "insert into t values (1)"], + false, + ) + syncOnScreen([cell]) + void engine.refresh("g1") + await flushAsync() + + // When the user cancels the write statement mid-validation + engine.cancelSlot("g1", keyOf("insert into t values (1)")) + validations.get("select 1")?.(dqlValidation) + validations.get("insert into t values (1)")?.({ queryType: "INSERT" }) + await flushAsync() + + // Then the barrier still blocks the cell — the sibling never launches + expect(deps.executeSingle).not.toHaveBeenCalled() + expect(engine.getState("g1")?.classifyBlock).toEqual({ + kind: "write", + queryType: "INSERT", + }) + }) + + it("seeds persisted refresh errors for surviving statements only", async () => { + // Given errors seeded before the entry exists — one for a statement the + // cell no longer contains + engine.seedRefreshErrors("g1", [ + { statementKey: keyOf("select 1"), message: "old failure" }, + { statementKey: keyOf("select gone"), message: "dropped" }, + ]) + + // When the cell syncs into the engine + const cell = gridCell("g1", "select 1", ["select 1"], false) + syncOnScreen([cell]) + await flushAsync() + + // Then only the surviving statement's error re-enters the channel + const state = engine.getState("g1") + expect(state?.slotErrors.get(keyOf("select 1"))).toBe("old failure") + expect(state?.slotErrors.size).toBe(1) + }) + + it("reshapes the frame on an SQL edit and drops the edited statement's error", async () => { + // Given a grid with refresh errors on both statements + const cell = gridCell( + "g1", + "select 1; select 2", + ["select 1", "select 2"], + false, + ) + syncOnScreen([cell]) + await flushAsync() + engine.seedRefreshErrors("g1", [ + { statementKey: keyOf("select 1"), message: "e1" }, + { statementKey: keyOf("select 2"), message: "e2" }, + ]) + + // When the second statement is edited and the debounce expires + engine.sync([{ ...cell, value: "select 1; select 3" }]) + await vi.advanceTimersByTimeAsync(1000) + + // Then the surviving statement keeps its rows and its error; the edited + // one dropped both + const written = cellResults.get("g1") + expect(written?.results.map((r) => r.query)).toEqual(["select 1"]) + const state = engine.getState("g1") + expect(state?.slotErrors.get(keyOf("select 1"))).toBe("e1") + expect(state?.slotErrors.size).toBe(1) + }) + + it("refreshAll skips write grids and reports the counts", async () => { + // Given a refreshable grid and a classified write grid + validateBySql({ "insert into t values (1)": { queryType: "INSERT" } }) + const readable = gridCell("g1", "select 1", ["select 1"], false) + const write = gridCell( + "g2", + "insert into t values (1)", + ["insert into t values (1)"], + false, + ) + syncOnScreen([readable, write]) + await flushAsync() + + // When refresh-all fires + const counts = engine.refreshAll() + await flushAsync() + + // Then the write grid is skipped and reported + expect(counts).toEqual({ refreshed: 1, skippedWrites: 1 }) + expect(deps.executeSingle.mock.calls.map(([sql]) => sql)).toEqual([ + "select 1", + ]) + }) + + it("redeems refresh-all for a released grid once its snapshot load settles", async () => { + // Given an off-screen grid cell whose rows were released to disk + harness.beginLoadOnRequest() + engine.sync([ + { + id: "g1", + position: 0, + value: "select 1", + mode: "run", + autoRefresh: false, + lastRunStatus: "success", + }, + ]) + await flushAsync() + expect(deps.executeSingle).not.toHaveBeenCalled() + + // When the user clicks refresh-all and then scrolls the cell in + engine.refreshAll() + engine.setVisible("g1", true) + await flushAsync() + + // Then the click waits on the snapshot load instead of settling silently + expect(deps.requestResultLoad).toHaveBeenCalledWith("g1") + expect(deps.executeSingle).not.toHaveBeenCalled() + + // And once the snapshot lands, the refresh runs against it + harness.settleLoad("g1", gridFrame(["select 1"])) + await flushAsync() + expect(deps.executeSingle.mock.calls.map(([sql]) => sql)).toEqual([ + "select 1", + ]) + }) + + it("revives the retained snapshot when the SQL settles back after a collapse", async () => { + // Given a settled grid whose text collapses mid-edit + const cell = gridCell("g1", "select 1", ["select 1"], false) + syncOnScreen([cell]) + await flushAsync() + harness.beginLoadOnRequest() + engine.sync([{ ...cell, value: "sel" }]) + await vi.advanceTimersByTimeAsync(400) + expect(cellResults.get("g1")).toBeUndefined() + + // When the text settles back to the original SQL + engine.sync([{ ...cell, value: "select 1" }]) + await vi.advanceTimersByTimeAsync(400) + + // Then the engine asks hydration for a revive and the restored frame + // lands without a run + expect(deps.reviveResultLoad).toHaveBeenCalledWith("g1") + harness.settleLoad("g1", gridFrame(["select 1"])) + expect(cellResults.get("g1")?.results[0]).toMatchObject({ + query: "select 1", + }) + }) + + it("consumes refresh-all when the released grid's snapshot is gone", async () => { + // Given an off-screen released grid whose snapshot load settles missing + harness.beginLoadOnRequest() + engine.sync([ + { + id: "g1", + position: 0, + value: "select 1", + mode: "run", + autoRefresh: false, + lastRunStatus: "success", + }, + ]) + await flushAsync() + + // When the click lands, the cell scrolls in, and the load finds nothing + engine.refreshAll() + engine.setVisible("g1", true) + await flushAsync() + harness.settleLoad("g1", "missing") + await flushAsync() + + // Then the click settles without fetching — nothing loops + expect(deps.executeSingle).not.toHaveBeenCalled() + const state = engine.getState("g1") + expect(state?.settledKey).toBe(state?.queriesKey) + }) + + it("skips a tick while the cell runs", async () => { + // Given a grid whose cell has a run in flight + deps.isCellRunning.mockReturnValue(true) + const cell = gridCell("g1", "select 1", ["select 1"], false) + syncOnScreen([cell]) + await flushAsync() + + // When a refresh fires + void engine.refresh("g1") + await flushAsync() + + // Then nothing executes — the manual run always wins + expect(deps.executeSingle).not.toHaveBeenCalled() + }) + + it("blocks refreshes for the whole run span, the classification barrier included", async () => { + // Given a run announced to the engine while runningCellIds still lags — + // the run is classifying, so isCellRunning reports false + const cell = gridCell("g1", "select 1", ["select 1"], false) + syncOnScreen([cell]) + await flushAsync() + engine.noteRunStarted("g1") + + // When a refresh fires inside that window + void engine.refresh("g1") + await flushAsync() + + // Then no round starts — the run owns the cell until it finishes + expect(deps.executeSingle).not.toHaveBeenCalled() + + // When the run finishes + engine.noteRunFinished("g1") + void engine.refresh("g1") + await flushAsync() + + // Then refreshes work again + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + }) + + it("keeps the gate closed until the last overlapping run finishes", async () => { + // Given a run superseded by a second run on the same cell + const cell = gridCell("g1", "select 1", ["select 1"], false) + syncOnScreen([cell]) + await flushAsync() + engine.noteRunStarted("g1") + engine.noteRunStarted("g1") + + // When only the superseded run finishes + engine.noteRunFinished("g1") + void engine.refresh("g1") + await flushAsync() + + // Then the gate holds for the run still in flight + expect(deps.executeSingle).not.toHaveBeenCalled() + + // And it reopens when that run finishes too + engine.noteRunFinished("g1") + void engine.refresh("g1") + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + }) + + it("cancels the in-flight round when a run starts and commits nothing from it", async () => { + // Given a grid round whose statement response is still in flight + let resolveFetch: () => void = () => {} + deps.executeSingle.mockImplementation( + (sql: string) => + new Promise((resolve) => { + resolveFetch = () => resolve(dqlResult(sql)) + }), + ) + const cell = gridCell("g1", "select 1", ["select 1"], false) + syncOnScreen([cell]) + await flushAsync() + const before = cellResults.get("g1") + void engine.refresh("g1") + await flushAsync() + expect(engine.getState("g1")?.fetching).toBe(true) + + // When a run starts and the late response lands afterwards + engine.noteRunStarted("g1") + resolveFetch() + await flushAsync() + + // Then the round is cancelled: no spinner remains and the aborted + // slot never swaps its rows into the frame + expect(engine.getState("g1")?.fetching).toBe(false) + expect(engine.getState("g1")?.slotFetching.size).toBe(0) + expect(cellResults.get("g1")).toBe(before) + }) + + const changingResults = () => { + let tick = 0 + deps.executeSingle.mockImplementation((sql: string) => { + tick += 1 + return Promise.resolve({ + type: "dql", + query: sql, + columns: [{ name: "x", type: "INT" }], + dataset: [[100 + tick]], + count: 1, + }) + }) + } + + // An automatic tick inside the 10s window parks its frame in the pending + // snapshot; hiding the cell stops further ticks so the pending frame is + // the only candidate write left. + const parkThrottledFrame = async () => { + changingResults() + const cell = gridCell("g1", "select 1", ["select 1"], "1s") + syncOnScreen([cell]) + await flushAsync() + expect(persistCellSnapshot).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1000) + engine.setVisible("g1", false) + expect(persistCellSnapshot).toHaveBeenCalledTimes(1) + } + + it("a run commit drops the throttle-blocked frame — pre-run rows never overwrite the run's snapshot", async () => { + // Given an automatic tick parked behind the snapshot throttle + await parkThrottledFrame() + + // When a run commits (the run path persists its own snapshot) + engine.noteCellRan("g1") + + // Then neither the reopening window nor an explicit flush writes the + // stale pre-run frame over the run's record + await vi.advanceTimersByTimeAsync(15_000) + await engine.flushPendingSnapshots() + expect(persistCellSnapshot).toHaveBeenCalledTimes(1) + }) + + it("a per-statement rerun commit drops the throttle-blocked frame too", async () => { + // Given an automatic tick parked behind the snapshot throttle + await parkThrottledFrame() + + // When a rerun commits (the rerun path persists the whole frame) + engine.noteStatementRan("g1", keyOf("select 1")) + + // Then the stale pending frame never persists + await vi.advanceTimersByTimeAsync(15_000) + await engine.flushPendingSnapshots() + expect(persistCellSnapshot).toHaveBeenCalledTimes(1) + }) + + it("noteStatementRan clears one slot's error; noteCellRan clears them all", async () => { + // Given a refresh round that failed both statements + deps.executeSingle.mockImplementation((sql: string) => + Promise.resolve({ + type: "error", + query: sql, + columns: [], + dataset: [], + count: 0, + error: "boom", + }), + ) + const cell = gridCell( + "g1", + "select 1; select 2", + ["select 1", "select 2"], + false, + ) + syncOnScreen([cell]) + await flushAsync() + void engine.refresh("g1") + await flushAsync() + expect(engine.getState("g1")?.slotErrors.size).toBe(2) + + // When one statement is rerun + engine.noteStatementRan("g1", keyOf("select 1")) + + // Then only its badge clears + expect(engine.getState("g1")?.slotErrors.size).toBe(1) + expect(engine.getState("g1")?.slotErrors.get(keyOf("select 2"))).toBe( + "boom", + ) + + // And a full run clears the rest + engine.noteCellRan("g1") + expect(engine.getState("g1")?.slotErrors.size).toBe(0) + }) + + it("a per-statement rerun writes surviving sibling refresh errors back to disk", async () => { + // Given a two-statement grid where only the second statement's refresh fails + deps.executeSingle.mockImplementation((sql: string) => + sql === "select 2" + ? Promise.resolve({ + type: "error", + query: sql, + columns: [], + dataset: [], + count: 0, + error: "boom", + }) + : Promise.resolve(dqlResult(sql)), + ) + const cell = gridCell( + "g1", + "select 1; select 2", + ["select 1", "select 2"], + false, + ) + syncOnScreen([cell]) + await flushAsync() + void engine.refresh("g1") + await flushAsync() + const persistsBefore = vi.mocked(persistCellSnapshot).mock.calls.length + + // When the healthy statement is rerun (the rerun path wrote a record + // that carries no refreshErrors) + engine.noteStatementRan("g1", keyOf("select 1")) + + // Then the engine re-persists the frame so the sibling's failure + // survives a reload + const calls = vi.mocked(persistCellSnapshot).mock.calls + expect(calls.length).toBe(persistsBefore + 1) + expect(calls[calls.length - 1][0].refreshErrors).toEqual([ + { statementKey: keyOf("select 2"), message: "boom" }, + ]) + }) + + it("a run commit clears the refresh stamps so the run frame's own timestamp takes over", async () => { + // Given a grid whose manual refresh swapped fresh rows in (stamps set) + changingResults() + const cell = gridCell("g1", "select 1", ["select 1"], false) + syncOnScreen([cell]) + await flushAsync() + void engine.refresh("g1") + await flushAsync() + const key = keyOf("select 1") + expect(engine.getState("g1")?.slotFetchedAt.get(key)).toBeDefined() + expect(engine.getState("g1")?.slotSwappedAt.get(key)).toBeDefined() + + // When a run commits a new frame + engine.noteCellRan("g1") + + // Then the stamps are gone — the status line and the viewport token + // must follow the run, not the superseded refresh round + expect(engine.getState("g1")?.slotFetchedAt.size).toBe(0) + expect(engine.getState("g1")?.slotSwappedAt.size).toBe(0) + }) + + it("a per-statement rerun clears only that statement's stamps", async () => { + // Given a two-statement grid with refresh stamps on both slots + changingResults() + const cell = gridCell( + "g1", + "select 1; select 2", + ["select 1", "select 2"], + false, + ) + syncOnScreen([cell]) + await flushAsync() + void engine.refresh("g1") + await flushAsync() + expect(engine.getState("g1")?.slotFetchedAt.size).toBe(2) + + // When one statement is rerun + engine.noteStatementRan("g1", keyOf("select 1")) + + // Then only its stamps drop; the sibling keeps its refresh times + const state = engine.getState("g1") + expect(state?.slotFetchedAt.has(keyOf("select 1"))).toBe(false) + expect(state?.slotSwappedAt.has(keyOf("select 1"))).toBe(false) + expect(state?.slotFetchedAt.has(keyOf("select 2"))).toBe(true) + expect(state?.slotSwappedAt.has(keyOf("select 2"))).toBe(true) + }) + + it("a superseded round's late settle leaves the replacement round's slot state intact", async () => { + // Given a manual refresh whose fetch hangs + const resolvers: Array<() => void> = [] + deps.executeSingle.mockImplementation( + (sql: string) => + new Promise((resolve) => { + resolvers.push(() => resolve(dqlResult(sql))) + }), + ) + const cell = gridCell("g1", "select 1", ["select 1"], false) + syncOnScreen([cell]) + await flushAsync() + void engine.refresh("g1") + await flushAsync() + expect(resolvers).toHaveLength(1) + + // When a second refresh supersedes it and registers the same statement + void engine.refresh("g1") + await flushAsync() + expect(resolvers).toHaveLength(2) + + // And the aborted round's response lands late + resolvers[0]() + await flushAsync() + + // Then the replacement round still reports its slot as fetching — the + // "Refreshing..." line stays up and Cancel stays routable + expect(engine.getState("g1")?.slotFetching.has(keyOf("select 1"))).toBe( + true, + ) + expect(engine.getState("g1")?.fetching).toBe(true) + + // And the replacement settles normally + resolvers[1]() + await flushAsync() + expect(engine.getState("g1")?.slotFetching.size).toBe(0) + expect(engine.getState("g1")?.fetching).toBe(false) + }) + + it("a cell refresh inside the SQL debounce promotes the edit and fetches only the new SQL", async () => { + // Given a settled grid whose SQL was just edited (debounce pending) + const cell = gridCell("g1", "select 1", ["select 1"], false) + syncOnScreen([cell]) + await flushAsync() + engine.sync([{ ...cell, value: "select 2" }]) + + // When the user clicks the cell's refresh before the debounce expires + void engine.refresh("g1") + await flushAsync() + + // Then the promoted SQL executes — never the stale statement + expect(deps.executeSingle.mock.calls.map(([sql]) => sql)).toEqual([ + "select 2", + ]) + + // And the debounce expiry adds nothing + await vi.advanceTimersByTimeAsync(301) + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + }) + + it("a poll tick never aborts an in-flight manual refresh", async () => { + // Given a settled 1s-interval grid + const cell = gridCell("g1", "select 1", ["select 1"], "1s") + syncOnScreen([cell]) + await flushAsync() + const settledCalls = deps.executeSingle.mock.calls.length + + // When a manual refresh starts and hangs across several intervals + let resolveManual: () => void = () => {} + deps.executeSingle.mockImplementation( + (sql: string) => + new Promise((resolve) => { + resolveManual = () => resolve(dqlResult(sql)) + }), + ) + void engine.refresh("g1") + await flushAsync() + expect(deps.executeSingle.mock.calls.length).toBe(settledCalls + 1) + await vi.advanceTimersByTimeAsync(2500) + + // Then the ticks are skipped instead of aborting the manual round + expect(deps.executeSingle.mock.calls.length).toBe(settledCalls + 1) + expect(engine.getState("g1")?.fetching).toBe(true) + + // And the user's own round settles + resolveManual() + await flushAsync() + expect(engine.getState("g1")?.fetching).toBe(false) + }) + + it("a mid-edit collapse drops the frame but keeps the persisted snapshot", async () => { + // Given a settled grid + const cell = gridCell("g1", "select 1", ["select 1"], false) + syncOnScreen([cell]) + await flushAsync() + + // When a transient mid-typing state matches no statement and the + // debounce fires + engine.sync([{ ...cell, value: "select 1, b fro" }]) + await vi.advanceTimersByTimeAsync(301) + + // Then the display collapses, but the snapshot survives on disk — + // only a reload's reconcile against the completed text may delete it + expect(deps.setCellResult).toHaveBeenCalledWith("g1", undefined) + expect(deps.noteResultMissing).toHaveBeenCalledWith("g1") + expect(deleteCellSnapshot).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts new file mode 100644 index 000000000..64ede39ac --- /dev/null +++ b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts @@ -0,0 +1,1557 @@ +import type { QueryExecResult } from "../../../../hooks/useQueryExecution" +import { runAdaptivePollLoop } from "../../../../hooks/useAdaptivePoll" +import { sleep } from "../../../../utils/sleep" +import type { + AutoRefresh, + CellResult, + NotebookCell, + SingleQueryResult, +} from "../../../../store/notebook" +import type { ValidateQueryResult } from "../../../../utils/questdb/types" +import { + classifyStatements, + type ClassifiedStatement, +} from "../../../../utils/tools/permissions" +import { + statementRequestLimiter, + type RequestLimiter, +} from "../../../../utils/questdb/requestLimiter" +import { eventBus } from "../../../../modules/EventBus" +import { EventType } from "../../../../modules/EventBus/types" +import { getQueriesFromText, normalizeQueryText } from "../../Monaco/utils" +import { + autoRefreshIntervalMs, + NOTEBOOK_ROW_CAP, + reconcileCellResultForValue, + resolveAutoRefresh, + singleResultFromExec, + sqlHash, + statementKeysFor, + type StatementKey, +} from "../notebookUtils" +import { + type ChartResult, + resultsEquivalent, + successResults, + toChartResult, + toExecResult, +} from "../DrawCanvas/drawCanvasUtils" +import { + hasRunMarker, + type CellResultStatus, +} from "../resultHydration/cellResultHydration" +import type { CellRefreshView as CellRefreshAgentView } from "../../../../utils/notebooks/notebookController/notebookController" +import { deleteCellSnapshot } from "../../../../store/notebookResults" +import { persistCellSnapshot } from "../persistCellSnapshot" +import { PerKeyListeners } from "../perKeyListeners" + +const REFRESH_MIN_MS = 2000 +const REFRESH_MAX_MS = 60000 +const SQL_DEBOUNCE_MS = 300 +// Auto-refresh can poll every few seconds; throttle snapshot writes so a live +// cell doesn't churn IndexedDB. A reload restores the last saved frame. A +// round that leaves failures bypasses the throttle: the failure state must +// survive an immediate reload. +const SNAPSHOT_THROTTLE_MS = 10000 +const INITIAL_FETCH_JITTER_MS = 300 + +export type CellEntryKind = "chart" | "grid" + +export type CellClassifyBlock = + | { kind: "write"; queryType: string } + | { kind: "failed"; message: string } + +export type CellFetchState = { + queries: string[] + queriesKey: string + fetching: boolean + settledKey: string | null + classifyBlock: CellClassifyBlock | null + // The queriesKey the last completed classification described. While an + // edited cell awaits reclassification the UI keeps showing the last known + // class; the fresh barrier classification stays the enforcement gate. + classifiedKey: string | null + // Per-statement refresh channel. Grid slots report their own in-flight and + // failure state here (old rows stay visible); chart entries re-derive + // slotErrors from their settled frame so both views feed one + // last_refresh_error surface. + slotFetching: ReadonlySet + slotErrors: ReadonlyMap + cancelledSlots: ReadonlySet + // Wall-clock of each slot's last successful settle. Freshness is per tab: + // after a partial round the succeeded slots are newer than their siblings. + // Memory-only — a reload falls back to the frame's saved time. + slotFetchedAt: ReadonlyMap + // Wall-clock of each slot's last settle that CHANGED its rows. This is the + // grid's viewport/focus reset token: an identical refresh advances + // slotFetchedAt (the status line must show the poll that verified the rows) + // but not this, so the user keeps their scroll position. + slotSwappedAt: ReadonlyMap +} + +export type CellRefreshDeps = { + executeSingle: ( + sql: string, + signal?: AbortSignal, + limit?: number, + ) => Promise + validateWithGlobals: ( + sql: string, + signal?: AbortSignal, + ) => Promise + setCellResult: (cellId: string, result: CellResult | undefined) => void + getCellResult: (cellId: string) => CellResult | null | undefined + isDrawCell: (cellId: string) => boolean + isCellRunning: (cellId: string) => boolean + resultLoadStatus: (cellId: string) => CellResultStatus + subscribeResultLoad: (cellId: string, listener: () => void) => () => void + requestResultLoad: (cellId: string) => void + noteResultMissing: (cellId: string) => void + reviveResultLoad: (cellId: string) => void + onSnapshotPersisted: (cellId: string, results: SingleQueryResult[]) => void +} + +const QUERIES_KEY_SEPARATOR = "\u0001" + +const joinQueriesKey = (queries: string[]): string => + queries.join(QUERIES_KEY_SEPARATOR) + +const normalizedQueriesKey = (queriesKey: string): string => + queriesKey + .split(QUERIES_KEY_SEPARATOR) + .map(normalizeQueryText) + .join(QUERIES_KEY_SEPARATOR) + +export const pendingCellFetchState = (sql: string): CellFetchState => { + const queries = getQueriesFromText(sql) + return { + queries, + queriesKey: joinQueriesKey(queries), + fetching: false, + settledKey: null, + classifyBlock: null, + classifiedKey: null, + slotFetching: new Set(), + slotErrors: new Map(), + cancelledSlots: new Set(), + slotFetchedAt: new Map(), + slotSwappedAt: new Map(), + } +} + +export const deriveChartLoading = ( + state: CellFetchState, + chartResult: ChartResult, + resultLoading: boolean, +): { loading: boolean; refreshing: boolean } => { + const hasData = + chartResult.kind === "settled" && chartResult.results.length > 0 + const loading = + state.queries.length > 0 && + state.classifyBlock === null && + !hasData && + (state.settledKey !== state.queriesKey || + resultLoading || + (state.fetching && chartResult.kind !== "settled")) + return { loading, refreshing: state.fetching && !loading } +} + +const errorMessage = (cause: unknown): string => { + if (typeof cause === "string" && cause) return cause + if (cause instanceof Error && cause.message) return cause.message + return "Query failed" +} + +const errorExecResult = (query: string, cause: unknown): QueryExecResult => ({ + type: "error", + query, + columns: [], + dataset: [], + count: 0, + error: errorMessage(cause), +}) + +export type CellRefreshEngineOptions = { + initialFetchJitterMs?: number + requestLimiter?: RequestLimiter +} + +// Editor-only cells are never entries: charts are entries by mode, grids only +// while they show (or expect) a result frame. The grid engine never +// bootstraps — the first frame always comes from the run path. +const entryKindFor = (cell: NotebookCell): CellEntryKind | null => { + if (cell.type === "markdown") return null + if (cell.mode === "draw") return "chart" + if (cell.result != null || hasRunMarker(cell)) return "grid" + return null +} + +type EntrySyncKey = Pick & { + kind: CellEntryKind +} + +const sameEntryCells = ( + previous: EntrySyncKey[], + eligible: Array<{ cell: NotebookCell; kind: CellEntryKind }>, +): boolean => + previous.length === eligible.length && + eligible.every( + ({ cell, kind }, index) => + previous[index].id === cell.id && + previous[index].value === cell.value && + previous[index].autoRefresh === cell.autoRefresh && + previous[index].kind === kind, + ) + +type Entry = { + kind: CellEntryKind + cellId: string + sql: string + autoRefresh: AutoRefresh + visible: boolean + pendingManualRefresh: boolean + manualRefreshInFlight: boolean + // A zero-survivor collapse dropped the display but kept the disk snapshot; + // the next settle with no result asks hydration for a non-destructive + // revive, so undo gets its rows back. + snapshotRetained: boolean + ensureAttempted: boolean + lastFetchedAt: number + state: CellFetchState + sqlDebounce: ReturnType | null + pendingSql: string | null + inFlight: AbortController | null + slotAborts: Map + classifyAbort: AbortController | null + poll: AbortController | null + pollKey: string | null + resultLoadUnsubscribe: (() => void) | null + lastSnapshotAt: number + persistedResults: WeakMap + lastClearedSqlHash: string | null + pendingSnapshot: { results: SingleQueryResult[]; durationMs: number } | null + snapshotTimer: ReturnType | null +} + +export class CellRefreshEngine { + private entries = new Map() + private listeners = new PerKeyListeners() + private visibilityByCell = new Map() + private lastSyncedEntryCells: EntrySyncKey[] | null = null + private autoRefreshDefault: AutoRefresh | undefined + private documentHidden = false + private limitRequest: RequestLimiter + private initialFetchJitterMs: number + private pendingErrorSeeds = new Map< + string, + Array<{ statementKey: string; message: string }> + >() + // Runs in flight per cell, from noteRunStarted to noteRunFinished. A count, + // not a flag: a superseded run's finish must not reopen the gate while its + // replacement still runs. Keyed outside the entries so a run on a cell whose + // entry appears mid-run (first run of an editor-only cell) is still covered. + private pendingRunCounts = new Map() + + private refreshHandler = (payload?: { cellId?: string }) => { + if (payload?.cellId) void this.refresh(payload.cellId) + } + + private documentVisibilityHandler = () => { + const hidden = document.hidden + if (hidden === this.documentHidden) return + this.documentHidden = hidden + for (const entry of this.entries.values()) { + if (hidden) this.updatePoll(entry) + else if (entry.visible) this.resume(entry) + } + } + + constructor( + private bufferId: number, + private getDeps: () => CellRefreshDeps, + options: CellRefreshEngineOptions = {}, + ) { + this.limitRequest = options.requestLimiter ?? statementRequestLimiter + this.initialFetchJitterMs = + options.initialFetchJitterMs ?? INITIAL_FETCH_JITTER_MS + } + + attach() { + eventBus.subscribe( + EventType.NOTEBOOK_CELL_REFRESH_CHART, + this.refreshHandler, + ) + if (typeof document !== "undefined") { + this.documentHidden = document.hidden + document.addEventListener( + "visibilitychange", + this.documentVisibilityHandler, + ) + } + } + + destroy() { + eventBus.unsubscribe( + EventType.NOTEBOOK_CELL_REFRESH_CHART, + this.refreshHandler, + ) + if (typeof document !== "undefined") { + document.removeEventListener( + "visibilitychange", + this.documentVisibilityHandler, + ) + } + for (const cellId of [...this.entries.keys()]) { + this.removeEntry(cellId, "teardown") + } + this.visibilityByCell.clear() + this.pendingErrorSeeds.clear() + this.pendingRunCounts.clear() + this.lastSyncedEntryCells = null + } + + sync(cells: NotebookCell[], autoRefreshDefault?: AutoRefresh) { + const eligible = cells.flatMap((cell) => { + const kind = entryKindFor(cell) + return kind === null ? [] : [{ cell, kind }] + }) + const defaultChanged = autoRefreshDefault !== this.autoRefreshDefault + this.autoRefreshDefault = autoRefreshDefault + if ( + !defaultChanged && + this.lastSyncedEntryCells && + sameEntryCells(this.lastSyncedEntryCells, eligible) + ) + return + this.lastSyncedEntryCells = eligible.map(({ cell, kind }) => ({ + id: cell.id, + value: cell.value, + autoRefresh: cell.autoRefresh, + kind, + })) + const present = new Set() + for (const { cell, kind } of eligible) { + present.add(cell.id) + const entry = this.entries.get(cell.id) + if (entry && entry.kind !== kind) { + this.removeEntry(cell.id, "modeExited") + this.createEntry(cell, kind) + } else if (entry) { + this.updateEntry(entry, cell) + } else { + this.createEntry(cell, kind) + } + } + const cellIds = new Set(cells.map((cell) => cell.id)) + for (const cellId of [...this.entries.keys()]) { + if (!present.has(cellId)) { + this.removeEntry( + cellId, + cellIds.has(cellId) ? "modeExited" : "cellDeleted", + ) + } + } + // Visibility follows the CELL, not the entry: a chart→grid→chart toggle + // recreates the entry while the cell never leaves the viewport, so the + // observer won't re-report it. Only a deleted cell drops its record. + for (const cellId of [...this.visibilityByCell.keys()]) { + if (!cellIds.has(cellId)) this.visibilityByCell.delete(cellId) + } + for (const cellId of [...this.pendingErrorSeeds.keys()]) { + if (!cellIds.has(cellId)) this.pendingErrorSeeds.delete(cellId) + } + for (const cellId of [...this.pendingRunCounts.keys()]) { + if (!cellIds.has(cellId)) this.pendingRunCounts.delete(cellId) + } + } + + // The cell's refresh button: a deliberate user action, so a pending edit is + // promoted past its debounce and the frame is persisted immediately rather + // than waiting out the poll throttle. The manual flag shields the round + // from the poll loop's ticks until it settles. + refresh(cellId: string): Promise { + const entry = this.entries.get(cellId) + if (!entry) return Promise.resolve() + this.promotePendingSql(entry) + entry.manualRefreshInFlight = true + return this.fetchOnce(entry, true).then(() => undefined) + } + + // Cancel acts per statement. After the barrier it aborts that slot's + // execution; before the barrier it drops the execution intent only — the + // validation still completes, so the barrier settles with every class known + // and one DDL/DML statement still blocks the cell. + cancelSlot(cellId: string, statementKey: StatementKey) { + const entry = this.entries.get(cellId) + if (!entry || entry.kind !== "grid") return + const slotAbort = entry.slotAborts.get(statementKey) + if (slotAbort) { + slotAbort.abort() + return + } + if (!entry.inFlight) return + const cancelledSlots = new Set(entry.state.cancelledSlots) + cancelledSlots.add(statementKey) + this.setState(entry, { cancelledSlots }) + } + + refreshAll(): { refreshed: number; skippedWrites: number } { + let refreshed = 0 + let skippedWrites = 0 + for (const entry of this.entries.values()) { + if ( + entry.kind === "grid" && + entry.state.classifyBlock?.kind === "write" + ) { + skippedWrites++ + continue + } + refreshed++ + if (!entry.visible || this.documentHidden) { + entry.pendingManualRefresh = true + continue + } + // A repeat click must not abort the fetch the previous click started; a + // background poll fetch queried pre-click state, so supersede it. + if (entry.inFlight && entry.manualRefreshInFlight) continue + this.forceRefresh(entry) + } + return { refreshed, skippedWrites } + } + + private forceRefresh(entry: Entry) { + if (!entry.visible || this.documentHidden) { + entry.pendingManualRefresh = true + return + } + entry.pendingManualRefresh = false + this.promotePendingSql(entry) + entry.manualRefreshInFlight = true + entry.inFlight?.abort() + entry.inFlight = null + if (this.shouldPoll(entry)) { + entry.lastFetchedAt = 0 + entry.poll?.abort() + entry.poll = null + entry.pollKey = null + this.updatePoll(entry) + } else { + void this.fetchOnce(entry, true) + } + } + + private promotePendingSql(entry: Entry) { + if (entry.sqlDebounce) { + clearTimeout(entry.sqlDebounce) + entry.sqlDebounce = null + } + const sql = entry.pendingSql + entry.pendingSql = null + if (sql == null || sql === entry.sql) return + this.applySqlState(entry, sql) + } + + // Called by the notebook's cell visibility observer. Hiding pauses the poll + // (in-flight fetches finish and land); revealing resumes it, fetching + // immediately when the data is older than the cell's interval. + setVisible(cellId: string, visible: boolean) { + this.visibilityByCell.set(cellId, visible) + const entry = this.entries.get(cellId) + if (!entry || entry.visible === visible) return + entry.visible = visible + if (visible) this.resume(entry) + else this.updatePoll(entry) + } + + setOnlyVisible(cellIds: string[]) { + const visible = new Set(cellIds) + for (const cellId of this.entries.keys()) { + if (!visible.has(cellId)) this.setVisible(cellId, false) + } + for (const cellId of cellIds) this.setVisible(cellId, true) + } + + requestHydrate(cellId: string) { + const entry = this.entries.get(cellId) + if (!entry || entry.ensureAttempted) return + this.ensureData(entry) + } + + private resume(entry: Entry) { + if (entry.pendingManualRefresh) { + this.forceRefresh(entry) + return + } + this.ensureData(entry) + } + + getState(cellId: string): CellFetchState | undefined { + return this.entries.get(cellId)?.state + } + + isRefreshing(cellId: string): boolean { + return this.entries.get(cellId)?.state.fetching ?? false + } + + // The agent-facing view of every live entry: charts and grids alike report + // in-flight state and their last refresh failure from the same channel. + readRefreshState(): ReadonlyMap { + const out = new Map() + for (const entry of this.entries.values()) { + const firstError = [...entry.state.slotErrors.values()][0] + out.set(entry.cellId, { + refreshing: entry.state.fetching, + ...(firstError !== undefined ? { lastRefreshError: firstError } : {}), + ...(entry.state.classifyBlock?.kind === "write" + ? { autoRefreshBlocked: "contains_write" as const } + : {}), + }) + } + return out + } + + countWriteBlockedGrids(): number { + let count = 0 + for (const entry of this.entries.values()) { + if (entry.kind === "grid" && entry.state.classifyBlock?.kind === "write") + count++ + } + return count + } + + subscribe(cellId: string, listener: () => void): () => void { + return this.listeners.subscribe(cellId, listener) + } + + // Persisted refresh errors re-enter the channel on hydration, so a reload + // never hides a failed refresh. Seeds may arrive before the entry exists. + seedRefreshErrors( + cellId: string, + errors: Array<{ statementKey: string; message: string }>, + ) { + const entry = this.entries.get(cellId) + if (!entry) { + this.pendingErrorSeeds.set(cellId, errors) + return + } + this.applyErrorSeed(entry, errors) + } + + // A completed run replaces the frame wholesale — every refresh failure it + // could describe is gone with it, and so is a throttle-blocked refresh + // frame: flushing that later would overwrite the run's own snapshot with + // pre-run rows. The run persisted just now, so the throttle window restarts. + noteCellRan(cellId: string) { + const entry = this.entries.get(cellId) + if (!entry) return + this.dropPendingSnapshot(entry) + entry.lastSnapshotAt = Date.now() + // The refresh stamps describe rounds the run just superseded: leaving + // them would show the old refresh time under the run's rows and let a + // pre-run viewport restore onto them. Cleared, both fall back to the + // frame's own run timestamp. + if ( + entry.state.slotErrors.size === 0 && + entry.state.slotFetchedAt.size === 0 && + entry.state.slotSwappedAt.size === 0 + ) + return + this.setState(entry, { + slotErrors: new Map(), + slotFetchedAt: new Map(), + slotSwappedAt: new Map(), + }) + } + + // A per-statement rerun replaces one slot and persists the whole frame, so + // a throttle-blocked pre-rerun frame is stale wholesale; only that + // statement's refresh failure is. The rerun's own record carries no + // refreshErrors, so surviving sibling failures must be written back here — + // otherwise a reload would show them as clean over stale rows. + noteStatementRan(cellId: string, statementKey: StatementKey) { + const entry = this.entries.get(cellId) + if (!entry) return + this.dropPendingSnapshot(entry) + entry.lastSnapshotAt = Date.now() + this.clearSlotError(entry, statementKey) + this.clearSlotStamps(entry, statementKey) + if (entry.state.slotErrors.size > 0) this.persistGridFrame(entry, true) + } + + // A run start always wins over a refresh: the in-flight round is cancelled, + // and the pending-run count keeps new rounds out for the run's WHOLE span — + // the classification barrier included, where isCellRunning is still false. + noteRunStarted(cellId: string) { + this.pendingRunCounts.set( + cellId, + (this.pendingRunCounts.get(cellId) ?? 0) + 1, + ) + const entry = this.entries.get(cellId) + if (!entry) return + this.abortRound(entry) + entry.manualRefreshInFlight = false + if (entry.state.fetching || entry.state.slotFetching.size > 0) { + this.setState(entry, { fetching: false, slotFetching: new Set() }) + } + } + + // Balances noteRunStarted on EVERY outcome — denied, skipped, superseded, + // thrown included. A leaked count would not corrupt data; it would silently + // disable refresh for the cell, so the caller wires this in a finally. + noteRunFinished(cellId: string) { + const count = this.pendingRunCounts.get(cellId) ?? 0 + if (count <= 1) this.pendingRunCounts.delete(cellId) + else this.pendingRunCounts.set(cellId, count - 1) + } + + private isRunPending(cellId: string): boolean { + return (this.pendingRunCounts.get(cellId) ?? 0) > 0 + } + + private applyErrorSeed( + entry: Entry, + errors: Array<{ statementKey: string; message: string }>, + ) { + if (errors.length === 0) return + const slotKeys = new Set(statementKeysFor(entry.state.queries)) + const slotErrors = new Map(entry.state.slotErrors) + let changed = false + for (const { statementKey, message } of errors) { + if (!slotKeys.has(statementKey)) continue + slotErrors.set(statementKey, message) + changed = true + } + if (changed) this.setState(entry, { slotErrors }) + } + + private createEntry(cell: NotebookCell, kind: CellEntryKind) { + const entry: Entry = { + kind, + cellId: cell.id, + sql: cell.value, + autoRefresh: resolveAutoRefresh( + cell.autoRefresh, + this.autoRefreshDefault, + ), + state: pendingCellFetchState(cell.value), + visible: this.visibilityByCell.get(cell.id) ?? false, + pendingManualRefresh: false, + manualRefreshInFlight: false, + snapshotRetained: false, + ensureAttempted: false, + lastFetchedAt: 0, + sqlDebounce: null, + pendingSql: null, + inFlight: null, + slotAborts: new Map(), + classifyAbort: null, + poll: null, + pollKey: null, + resultLoadUnsubscribe: null, + lastSnapshotAt: 0, + persistedResults: new WeakMap(), + lastClearedSqlHash: null, + pendingSnapshot: null, + snapshotTimer: null, + } + this.entries.set(cell.id, entry) + const seed = this.pendingErrorSeeds.get(cell.id) + if (seed) { + this.pendingErrorSeeds.delete(cell.id) + this.applyErrorSeed(entry, seed) + } + if (kind === "grid") this.ensureClassified(entry) + if (entry.visible) this.ensureData(entry) + } + + private updateEntry(entry: Entry, cell: NotebookCell) { + const autoRefresh = resolveAutoRefresh( + cell.autoRefresh, + this.autoRefreshDefault, + ) + if (autoRefresh !== entry.autoRefresh) { + entry.autoRefresh = autoRefresh + this.updatePoll(entry) + } + const target = entry.pendingSql ?? entry.sql + if (cell.value === target) return + entry.pendingSql = cell.value + if (entry.sqlDebounce) clearTimeout(entry.sqlDebounce) + entry.sqlDebounce = setTimeout(() => { + entry.sqlDebounce = null + const sql = entry.pendingSql + entry.pendingSql = null + if (sql != null && sql !== entry.sql) this.applySql(entry, sql) + }, SQL_DEBOUNCE_MS) + } + + private removeEntry( + cellId: string, + reason: "cellDeleted" | "modeExited" | "teardown", + ) { + const entry = this.entries.get(cellId) + if (!entry) return + if (entry.sqlDebounce) clearTimeout(entry.sqlDebounce) + const pending = entry.pendingSnapshot + this.dropPendingSnapshot(entry) + if (reason === "teardown" && pending) { + entry.lastSnapshotAt = 0 + this.queueSnapshot(entry, pending.results, pending.durationMs) + } + this.stopResultLoadWait(entry) + this.abortRound(entry) + entry.classifyAbort?.abort() + entry.classifyAbort = null + entry.poll?.abort() + this.entries.delete(cellId) + // Subscribers re-derive from the now-missing state and settle on idle, so + // the toolbar is not stranded spinning after the entry is gone. + this.notify(cellId) + } + + private abortRound(entry: Entry) { + entry.inFlight?.abort() + entry.inFlight = null + for (const abort of entry.slotAborts.values()) abort.abort() + entry.slotAborts.clear() + } + + private applySqlState(entry: Entry, sql: string) { + this.abortRound(entry) + entry.manualRefreshInFlight = false + this.stopResultLoadWait(entry) + entry.sql = sql + entry.lastFetchedAt = 0 + // The snapshot throttle window belongs to the previous SQL, and so does a + // pending frame it blocked — the next save must not inherit either. + entry.lastSnapshotAt = 0 + this.dropPendingSnapshot(entry) + const queries = getQueriesFromText(sql) + const queriesKey = joinQueriesKey(queries) + const sameQueries = + entry.state.settledKey !== null && + normalizedQueriesKey(entry.state.settledKey) === + normalizedQueriesKey(queriesKey) + // Refresh errors follow statement content: an edited statement's error + // clears, an unchanged sibling's survives the edit. + const slotKeys = new Set(statementKeysFor(queries)) + const slotErrors = new Map( + [...entry.state.slotErrors].filter(([key]) => slotKeys.has(key)), + ) + const slotFetchedAt = new Map( + [...entry.state.slotFetchedAt].filter(([key]) => slotKeys.has(key)), + ) + const slotSwappedAt = new Map( + [...entry.state.slotSwappedAt].filter(([key]) => slotKeys.has(key)), + ) + this.setState(entry, { + queries, + queriesKey, + fetching: false, + slotFetching: new Set(), + cancelledSlots: new Set(), + slotErrors, + slotFetchedAt, + slotSwappedAt, + ...(sameQueries ? { settledKey: queriesKey } : {}), + }) + if (entry.kind === "grid") this.ensureClassified(entry) + } + + private applySql(entry: Entry, sql: string) { + this.applySqlState(entry, sql) + if (entry.kind === "grid") this.reconcileGridResult(entry) + if (entry.visible) this.ensureData(entry) + else entry.ensureAttempted = false + } + + // Editor typing reshapes the visible frame after the debounce: unchanged + // statements keep their results, edited ones drop, zero survivors collapse + // the cell. Agent paths reconcile in their transitions; this pass is + // idempotent on top of them. + private reconcileGridResult(entry: Entry) { + const deps = this.getDeps() + const current = deps.getCellResult(entry.cellId) + if (current == null) { + // A previous collapse kept the snapshot on disk. Now that the SQL has + // settled again, ask hydration for a non-destructive reload: a matching + // text (undo, retype) gets its rows back, anything else leaves the + // snapshot exactly as it was. + if (entry.snapshotRetained) deps.reviveResultLoad(entry.cellId) + return + } + entry.snapshotRetained = false + const reconciled = reconcileCellResultForValue(current, entry.sql) + if (reconciled === null) { + // The debounce fires on transient mid-typing text, so a zero-survivor + // collapse drops the display only — never the disk snapshot. "missing" + // blocks an in-session reload; the next load reconciles the snapshot + // against the completed text and deletes it only then. + deps.setCellResult(entry.cellId, undefined) + deps.noteResultMissing(entry.cellId) + entry.snapshotRetained = true + return + } + const unchanged = + reconciled.results.length === current.results.length && + reconciled.results.every((r, index) => r === current.results[index]) && + reconciled.activeStatementKey === current.activeStatementKey + if (!unchanged) deps.setCellResult(entry.cellId, reconciled) + } + + // Settle from the data already in cell.result — the just-run grid, the + // engine's own last frame, or a snapshot the hydration engine restored — + // instead of re-querying. Waits for an in-flight snapshot load. Chart + // entries fall back to a live fetch when nothing usable exists for the + // CURRENT queries; grid entries never bootstrap — their first frame always + // comes from the run path. + // + // Never runs for a cell outside the bands: creation and applySql defer it + // until the retain band (requestHydrate) or a reveal (resume) asks — the + // same mount/retain contract run-cell results follow. + private ensureData(entry: Entry) { + entry.ensureAttempted = true + this.stopResultLoadWait(entry) + const { queries, queriesKey, settledKey, classifyBlock } = entry.state + if (queries.length === 0) { + if (entry.kind === "chart" && settledKey !== queriesKey) { + void this.fetchOnce(entry) + } + this.updatePoll(entry) + return + } + if (classifyBlock !== null && settledKey === queriesKey) { + this.updatePoll(entry) + return + } + if (entry.kind === "grid") { + this.ensureGridData(entry) + return + } + const chartResult = toChartResult( + this.getDeps().getCellResult(entry.cellId), + queries, + ) + if ( + chartResult.kind === "settled" && + (chartResult.results.length > 0 || settledKey === queriesKey) + ) { + this.setState(entry, { settledKey: queriesKey }) + this.deriveChartSlotErrors(entry) + entry.lastFetchedAt = chartResult.timestamp + this.updatePoll(entry) + return + } + if (this.waitForResultLoad(entry)) return + if (this.shouldPoll(entry)) { + // No usable data at this point — a poll still sleeping on a pre-release + // lastFetchedAt must not defer the refetch, so restart the loop with an + // immediate first tick. + entry.lastFetchedAt = 0 + entry.poll?.abort() + entry.poll = null + entry.pollKey = null + this.updatePoll(entry) + return + } + if (entry.visible && !this.documentHidden) void this.fetchOnce(entry) + this.updatePoll(entry) + } + + private ensureGridData(entry: Entry) { + const result = this.getDeps().getCellResult(entry.cellId) + if (result != null) { + this.setState(entry, { settledKey: entry.state.queriesKey }) + entry.lastFetchedAt = result.timestamp + this.updatePoll(entry) + return + } + if (this.waitForResultLoad(entry)) return + // Nothing to refresh (snapshot missing or gone) — stay idle, never fetch. + this.updatePoll(entry) + } + + // Returns true when a snapshot load is now pending; resume re-enters on + // settle, so scheduling — and a pending refresh click — continues only + // after hydration finishes. + private waitForResultLoad(entry: Entry): boolean { + if (this.getDeps().resultLoadStatus(entry.cellId) === "unrequested") { + this.getDeps().requestResultLoad(entry.cellId) + } + if (this.getDeps().resultLoadStatus(entry.cellId) !== "loading") { + return false + } + entry.resultLoadUnsubscribe = this.getDeps().subscribeResultLoad( + entry.cellId, + () => { + const status = this.getDeps().resultLoadStatus(entry.cellId) + if (status !== "loaded" && status !== "missing" && status !== "failed") + return + this.stopResultLoadWait(entry) + this.resume(entry) + }, + ) + this.updatePoll(entry) + return true + } + + private stopResultLoadWait(entry: Entry) { + entry.resultLoadUnsubscribe?.() + entry.resultLoadUnsubscribe = null + } + + private shouldAutoRefresh(entry: Entry): boolean { + return ( + entry.autoRefresh !== false && + entry.state.queries.length > 0 && + // A write grid is a blocked entry: it carries its classification but + // never ticks. Charts keep their own gate inside the fetch. + !(entry.kind === "grid" && entry.state.classifyBlock?.kind === "write") + ) + } + + private shouldPoll(entry: Entry): boolean { + return ( + this.shouldAutoRefresh(entry) && entry.visible && !this.documentHidden + ) + } + + private updatePoll(entry: Entry) { + const enabled = this.shouldPoll(entry) + const key = enabled + ? `${entry.state.queriesKey}\u0001${String(entry.autoRefresh)}` + : null + if (entry.pollKey === key) return + entry.poll?.abort() + entry.poll = null + entry.pollKey = key + if (!enabled) { + // A manual refresh still waiting out the start jitter dies with the + // poll — hand it back to pending so resume() redeems the click. + if (entry.manualRefreshInFlight && !entry.inFlight) { + entry.manualRefreshInFlight = false + entry.pendingManualRefresh = true + } + return + } + const abort = new AbortController() + entry.poll = abort + void this.runPollLoop(entry, abort) + } + + // The jitter offsets each loop's start so charts starting together don't tick together. + private async runPollLoop(entry: Entry, abort: AbortController) { + if (this.initialFetchJitterMs > 0) { + const jitter = Math.random() * this.initialFetchJitterMs + const aborted = await sleep(jitter, abort.signal) + if (aborted) return + } + const fixed = autoRefreshIntervalMs(entry.autoRefresh) + const skipInitialFetch = + Date.now() - entry.lastFetchedAt < (fixed ?? REFRESH_MIN_MS) + await runAdaptivePollLoop({ + fetchFn: () => this.fetchOnce(entry), + signal: abort.signal, + minIntervalMs: fixed ?? REFRESH_MIN_MS, + maxIntervalMs: fixed ?? REFRESH_MAX_MS, + skipInitialFetch, + }) + } + + private async fetchOnce( + entry: Entry, + manual: boolean = false, + ): Promise { + // A poll tick must not abort the round a refresh click started — skip it; + // the loop resumes on its own schedule once the manual round settles. + if (!manual && entry.inFlight && entry.manualRefreshInFlight) return + // Supersede any in-flight round up front, so a slow earlier response can't + // land after the query changed — including when it's cleared to empty. + this.abortRound(entry) + this.stopResultLoadWait(entry) + const { queries, queriesKey } = entry.state + if (queries.length === 0) { + entry.manualRefreshInFlight = false + this.setState(entry, { + fetching: false, + settledKey: queriesKey, + classifyBlock: null, + slotFetching: new Set(), + slotErrors: new Map(), + cancelledSlots: new Set(), + }) + if (entry.kind === "chart") this.clearCellData(entry) + return + } + // A manual run always wins: a tick never races the run path's frame. + // isRunPending covers the classification barrier and the one-frame lag + // before runningCellIds reaches the deps closure; isCellRunning stays as + // the backstop for a run the provider never announced. + if ( + entry.kind === "grid" && + (this.isRunPending(entry.cellId) || + this.getDeps().isCellRunning(entry.cellId)) + ) { + entry.manualRefreshInFlight = false + return + } + // A grid refresh re-runs an existing frame; with nothing on screen there + // is nothing to refresh — the first frame always comes from the run path. + if ( + entry.kind === "grid" && + this.getDeps().getCellResult(entry.cellId) == null + ) { + // A refresh click can land before a released snapshot hydrates — hand + // the intent back to pending and redeem it when the load settles. + if ( + (manual || entry.manualRefreshInFlight) && + this.waitForResultLoad(entry) + ) { + entry.manualRefreshInFlight = false + entry.pendingManualRefresh = true + return + } + entry.manualRefreshInFlight = false + this.setState(entry, { settledKey: queriesKey }) + return + } + const ac = new AbortController() + entry.inFlight = ac + this.setState(entry, { fetching: true, cancelledSlots: new Set() }) + const start = performance.now() + // A refresh-all click on a polling cell redeems itself through the poll + // loop's first tick, so the manual intent rides on the entry, not the call. + const userAsked = manual || entry.manualRefreshInFlight + if (entry.kind === "grid") await this.runGridRound(entry, ac, userAsked) + else await this.runChartFetch(entry, ac) + return performance.now() - start + } + + // One classification per launch: the barrier result is the single decision + // for the write gate and slot exclusion. Validation requests are ROUND-owned + // (a slot cancel never aborts them — the barrier needs every class); + // execution requests are SLOT-owned. + private classifyForRound( + entry: Entry, + round: AbortController, + ): Promise { + return classifyStatements(entry.sql, (sql) => + this.limitRequest( + () => this.getDeps().validateWithGlobals(sql, round.signal), + round.signal, + ), + ) + } + + private async runChartFetch(entry: Entry, ac: AbortController) { + const deps = this.getDeps() + const { queries, queriesKey } = entry.state + try { + // Runtime backstop: a user typing DDL into an already-draw cell would + // otherwise reach executeSingle on the next poll tick. A query failing + // validation is never executed — re-validating it every tick means an + // INSERT whose missing table appears later classifies as a write and + // gets blocked, instead of silently running. + let classified: ClassifiedStatement[] + try { + classified = await this.classifyForRound(entry, ac) + } catch (e) { + if (ac.signal.aborted) return + const message = e instanceof Error ? e.message : "validate failed" + this.setState(entry, { + classifyBlock: { kind: "failed", message }, + settledKey: queriesKey, + }) + return + } + if (ac.signal.aborted || !deps.isDrawCell(entry.cellId)) return + const offender = classified.find((s) => s.klass === "DDL_DML") + if (offender) { + this.setState(entry, { + classifyBlock: { + kind: "write", + queryType: offender.queryType ?? "write", + }, + classifiedKey: queriesKey, + settledKey: queriesKey, + }) + // The cell now holds a write — drop any stale rows the grid would show. + this.clearCellData(entry) + return + } + this.setState(entry, { classifyBlock: null, classifiedKey: queriesKey }) + const fetchStartedAt = Date.now() + const out = await Promise.all( + queries.map((q, index) => { + const stmt = classified[index] + if (stmt?.klass === "ERROR") + return Promise.resolve( + errorExecResult(q, stmt.error ?? "Invalid statement"), + ) + return this.limitRequest( + () => deps.executeSingle(q, ac.signal, NOTEBOOK_ROW_CAP), + ac.signal, + ).catch((e) => errorExecResult(q, e)) + }), + ) + const fetchDurationMs = Date.now() - fetchStartedAt + if (ac.signal.aborted || !deps.isDrawCell(entry.cellId)) return + // Compare against the CURRENT cell.result, not a retained copy — the + // hydration engine may have released or replaced it since the last tick, + // and an unchanged frame must still be re-written in that case. + const currentSqlHash = sqlHash(entry.sql) + const current = this.getDeps().getCellResult(entry.cellId) + if ( + current != null && + resultsEquivalent(current.results.map(toExecResult), out) + ) { + this.setState(entry, { settledKey: queriesKey }) + this.deriveChartSlotErrors(entry) + if (successResults(out).length === 0) { + this.clearSnapshot(entry) + return + } + const persisted = + entry.persistedResults.get(current.results) === currentSqlHash + if (!persisted) { + this.queueSnapshot(entry, current.results, fetchDurationMs) + } + return + } + // Write EVERY statement (not just chartable ones) so a switch to the grid + // shows the same tabs a real run would — including errors and empty + // results — instead of dropping them or leaving stale rows behind. The + // result lands before settledKey flips: React 17 renders the two updates + // separately, and a settled state without data would flash "No data". + const written = out.map((r) => singleResultFromExec(r, r.query)) + this.getDeps().setCellResult(entry.cellId, { + results: written, + activeResultIndex: 0, + timestamp: Date.now(), + }) + this.setState(entry, { settledKey: queriesKey }) + this.deriveChartSlotErrors(entry) + if (successResults(out).length > 0) { + this.queueSnapshot(entry, written, fetchDurationMs) + } else { + this.clearSnapshot(entry) + } + } finally { + this.finishRound(entry, ac) + } + } + + // The refresh unit is the statement: each slot swaps or fails alone while + // its old rows stay visible. An invalid statement is excluded with its + // validation error; one DDL/DML class blocks the whole cell (the engine + // never runs writes); a validate transport failure skips the round. + private async runGridRound( + entry: Entry, + round: AbortController, + manual: boolean, + ) { + const deps = this.getDeps() + const { queries, queriesKey } = entry.state + try { + let classified: ClassifiedStatement[] + try { + classified = await this.classifyForRound(entry, round) + } catch (e) { + if (round.signal.aborted) return + const message = e instanceof Error ? e.message : "validate failed" + this.setState(entry, { + classifyBlock: { kind: "failed", message }, + settledKey: queriesKey, + }) + return + } + if (round.signal.aborted) return + const offender = classified.find((s) => s.klass === "DDL_DML") + if (offender) { + // A write cell is a blocked entry: no execution, old rows stay. + this.setState(entry, { + classifyBlock: { + kind: "write", + queryType: offender.queryType ?? "write", + }, + classifiedKey: queriesKey, + settledKey: queriesKey, + }) + this.updatePoll(entry) + return + } + this.setState(entry, { classifyBlock: null, classifiedKey: queriesKey }) + const slotKeys = statementKeysFor(queries) + let hadFailure = false + await Promise.all( + slotKeys.map(async (key, index) => { + if (round.signal.aborted) return + if (entry.state.cancelledSlots.has(key)) return + const stmt = classified[index] + if (stmt?.klass === "ERROR") { + hadFailure = true + this.setSlotError(entry, key, stmt.error ?? "Invalid statement") + return + } + const slotAbort = new AbortController() + entry.slotAborts.set(key, slotAbort) + this.setSlotFetching(entry, key, true) + try { + const exec = await this.limitRequest( + () => + deps.executeSingle( + queries[index], + slotAbort.signal, + NOTEBOOK_ROW_CAP, + ), + slotAbort.signal, + ) + if (slotAbort.signal.aborted || round.signal.aborted) return + if (exec.type === "error") { + hadFailure = true + this.setSlotError(entry, key, exec.error ?? "Query failed") + } else { + // The fetch time always advances — the status line shows the + // poll that just verified the rows. The swap token advances + // only when the rows changed: it drives the grid's + // viewport/focus reset, and an identical frame must not cost + // the user their scroll position. + const previous = this.currentSlotResult(entry.cellId, key) + const unchanged = + previous !== undefined && + resultsEquivalent([toExecResult(previous)], [exec]) + if (!unchanged) { + this.commitSlotResult(entry, key, exec) + this.stampSlotSwappedAt(entry, key) + } + this.stampSlotFetchedAt(entry, key) + this.clearSlotError(entry, key) + } + } catch (e) { + if (slotAbort.signal.aborted || round.signal.aborted) return + hadFailure = true + this.setSlotError(entry, key, errorMessage(e)) + } finally { + // A superseded round's late settle must not clobber the slot the + // replacement round registered under the same key. + if (entry.slotAborts.get(key) === slotAbort) { + entry.slotAborts.delete(key) + this.setSlotFetching(entry, key, false) + } + } + }), + ) + if (round.signal.aborted) return + this.setState(entry, { settledKey: queriesKey }) + this.persistGridFrame(entry, hadFailure || manual) + } finally { + this.finishRound(entry, round) + } + } + + private finishRound(entry: Entry, ac: AbortController) { + // Only clear when still the active round — a superseded (aborted) one + // must not flip `fetching` off while its replacement is in flight. + if (entry.inFlight === ac) { + entry.inFlight = null + entry.manualRefreshInFlight = false + entry.lastFetchedAt = Date.now() + this.setState(entry, { fetching: false, slotFetching: new Set() }) + } + } + + private currentSlotResult( + cellId: string, + key: StatementKey, + ): SingleQueryResult | undefined { + const current = this.getDeps().getCellResult(cellId) + if (!current) return undefined + const keys = statementKeysFor(current.results.map((r) => r.query)) + const index = keys.indexOf(key) + return index === -1 ? undefined : current.results[index] + } + + // A settled slot swaps its rows into the visible frame in statement order. + // A statement without a previous result (added since the last run) lands + // like any other; the active tab follows its statement's content. + private commitSlotResult( + entry: Entry, + key: StatementKey, + exec: QueryExecResult, + ) { + const deps = this.getDeps() + const current = deps.getCellResult(entry.cellId) + if (!current) return + const slotKeys = statementKeysFor(entry.state.queries) + const slotIndex = slotKeys.indexOf(key) + if (slotIndex === -1) return + const currentKeys = statementKeysFor(current.results.map((r) => r.query)) + const byKey = new Map() + currentKeys.forEach((currentKey, index) => { + byKey.set(currentKey, current.results[index]) + }) + byKey.set(key, singleResultFromExec(exec, entry.state.queries[slotIndex])) + const nextKeys = slotKeys.filter((slotKey) => byKey.has(slotKey)) + const nextResults = nextKeys.map( + (slotKey) => byKey.get(slotKey) as SingleQueryResult, + ) + const activeKey = + current.activeStatementKey ?? + currentKeys[ + Math.min(Math.max(current.activeResultIndex, 0), currentKeys.length - 1) + ] + // The frame timestamp is every sibling tab's viewport token: bumping it + // per slot settle would reset their scroll. The settled slot's own token + // advances through slotFetchedAt instead. + deps.setCellResult(entry.cellId, { + ...current, + results: nextResults, + activeResultIndex: Math.max(0, nextKeys.indexOf(activeKey)), + ...(activeKey !== undefined ? { activeStatementKey: activeKey } : {}), + }) + } + + // The snapshot write unit is always the WHOLE visible frame — never an + // error alone. + // + // `immediate` bypasses the throttle. It covers every frame whose loss the + // user would notice: a round that left failures, and any round the user + // asked for by hand. Only automatic ticks stay throttled, and losing one is + // self-correcting — the next tick regenerates it. The pagehide flush is a + // best-effort backstop, not a guarantee: an IndexedDB write started during + // teardown is routinely dropped, so nothing durable may depend on it. + private persistGridFrame(entry: Entry, immediate: boolean) { + const current = this.getDeps().getCellResult(entry.cellId) + if (!current || current.results.length === 0) return + if (immediate) entry.lastSnapshotAt = 0 + this.queueSnapshot(entry, current.results, 0) + } + + private setSlotError(entry: Entry, key: StatementKey, message: string) { + const slotErrors = new Map(entry.state.slotErrors) + slotErrors.set(key, message.trim()) + this.setState(entry, { slotErrors }) + } + + private clearSlotError(entry: Entry, key: StatementKey) { + if (!entry.state.slotErrors.has(key)) return + const slotErrors = new Map(entry.state.slotErrors) + slotErrors.delete(key) + this.setState(entry, { slotErrors }) + } + + private clearSlotStamps(entry: Entry, key: StatementKey) { + if ( + !entry.state.slotFetchedAt.has(key) && + !entry.state.slotSwappedAt.has(key) + ) + return + const slotFetchedAt = new Map(entry.state.slotFetchedAt) + const slotSwappedAt = new Map(entry.state.slotSwappedAt) + slotFetchedAt.delete(key) + slotSwappedAt.delete(key) + this.setState(entry, { slotFetchedAt, slotSwappedAt }) + } + + private stampSlotFetchedAt(entry: Entry, key: StatementKey) { + const slotFetchedAt = new Map(entry.state.slotFetchedAt) + slotFetchedAt.set(key, Date.now()) + this.setState(entry, { slotFetchedAt }) + } + + private stampSlotSwappedAt(entry: Entry, key: StatementKey) { + const slotSwappedAt = new Map(entry.state.slotSwappedAt) + slotSwappedAt.set(key, Date.now()) + this.setState(entry, { slotSwappedAt }) + } + + private setSlotFetching(entry: Entry, key: StatementKey, fetching: boolean) { + const slotFetching = new Set(entry.state.slotFetching) + if (fetching) slotFetching.add(key) + else slotFetching.delete(key) + this.setState(entry, { slotFetching }) + } + + // Chart refresh failures live inside the settled frame (error results); the + // channel re-derives from it so both views feed one last_refresh_error + // surface — including after a reload. + private deriveChartSlotErrors(entry: Entry) { + const current = this.getDeps().getCellResult(entry.cellId) + const slotErrors = new Map() + if (current) { + const keys = statementKeysFor(current.results.map((r) => r.query)) + current.results.forEach((result, index) => { + if (result.type === "error") slotErrors.set(keys[index], result.error) + }) + } + const previous = entry.state.slotErrors + const same = + previous.size === slotErrors.size && + [...slotErrors].every(([key, value]) => previous.get(key) === value) + if (!same) this.setState(entry, { slotErrors }) + } + + // Classification has its own lifecycle, independent of polling: entry + // creation, hydration completion, and the debounced SQL change all classify + // — so an Off write grid still carries its block for the disabled selector + // and auto_refresh_blocked. The shared text cache absorbs the mount burst. + private ensureClassified(entry: Entry) { + entry.classifyAbort?.abort() + if (entry.state.queries.length === 0) { + entry.classifyAbort = null + return + } + const ac = new AbortController() + entry.classifyAbort = ac + const classifiedKey = entry.state.queriesKey + void classifyStatements(entry.sql, (sql) => + this.limitRequest( + () => this.getDeps().validateWithGlobals(sql, ac.signal), + ac.signal, + ), + ).then( + (classified) => { + if (ac.signal.aborted || entry.classifyAbort !== ac) return + entry.classifyAbort = null + if (this.entries.get(entry.cellId) !== entry) return + const offender = classified.find((s) => s.klass === "DDL_DML") + this.setState(entry, { + classifiedKey, + classifyBlock: offender + ? { kind: "write", queryType: offender.queryType ?? "write" } + : null, + }) + this.updatePoll(entry) + }, + () => { + // Transport failure: keep the last known class; the next tick, edit, + // or hydration retries. + if (entry.classifyAbort === ac) entry.classifyAbort = null + }, + ) + } + + private clearCellData(entry: Entry) { + this.getDeps().setCellResult(entry.cellId, undefined) + this.getDeps().noteResultMissing(entry.cellId) + this.clearSnapshot(entry) + } + + // Persist a throttled copy of the chart's frame — shared with run mode (one + // snapshot per cell) so the chart survives reload without re-fetch. A frame + // blocked by the throttle is kept pending and saved when the window reopens, + // so the final frame persists even when polling stops before the next tick. + private queueSnapshot( + entry: Entry, + results: SingleQueryResult[], + durationMs: number, + ) { + const now = Date.now() + const throttledForMs = SNAPSHOT_THROTTLE_MS - (now - entry.lastSnapshotAt) + if (throttledForMs > 0) { + entry.pendingSnapshot = { results, durationMs } + if (!entry.snapshotTimer) { + entry.snapshotTimer = setTimeout(() => { + entry.snapshotTimer = null + const pending = entry.pendingSnapshot + entry.pendingSnapshot = null + if (pending && this.entries.get(entry.cellId) === entry) { + this.queueSnapshot(entry, pending.results, pending.durationMs) + } + }, throttledForMs) + } + return + } + entry.lastSnapshotAt = now + void this.persistSnapshot(entry, results, durationMs) + } + + private persistSnapshot( + entry: Entry, + results: SingleQueryResult[], + durationMs: number, + ): Promise { + this.dropPendingSnapshot(entry) + const persistedSqlHash = sqlHash(entry.sql) + const current = this.getDeps().getCellResult(entry.cellId) + const failedCount = results.filter((r) => r.type === "error").length + const script = + entry.kind === "grid" + ? current?.script + : results.length > 1 + ? { + successCount: results.length - failedCount, + failedCount, + durationMs, + } + : undefined + // Grid refresh errors persist with the frame so a failed refresh is never + // hidden behind a reload; charts re-derive theirs from the frame itself. + const refreshErrors = + entry.kind === "grid" && entry.state.slotErrors.size > 0 + ? [...entry.state.slotErrors].map(([statementKey, message]) => ({ + statementKey, + message, + })) + : undefined + return persistCellSnapshot({ + bufferId: this.bufferId, + cellId: entry.cellId, + results, + savedAt: Date.now(), + activeResultIndex: current?.activeResultIndex ?? 0, + ...(current?.activeStatementKey !== undefined + ? { activeStatementKey: current.activeStatementKey } + : {}), + ...(script ? { script } : {}), + ...(refreshErrors ? { refreshErrors } : {}), + }).then((saved) => { + if (!saved) return + entry.persistedResults.set(results, persistedSqlHash) + entry.lastClearedSqlHash = null + this.getDeps().onSnapshotPersisted(entry.cellId, results) + }) + } + + async flushPendingSnapshots(): Promise { + const writes: Promise[] = [] + for (const entry of this.entries.values()) { + const pending = entry.pendingSnapshot + if (!pending) continue + entry.lastSnapshotAt = Date.now() + writes.push( + this.persistSnapshot(entry, pending.results, pending.durationMs), + ) + } + await Promise.all(writes) + } + + private clearSnapshot(entry: Entry) { + this.dropPendingSnapshot(entry) + const clearedSqlHash = sqlHash(entry.sql) + if (entry.lastClearedSqlHash === clearedSqlHash) return + void deleteCellSnapshot(this.bufferId, entry.cellId).then( + () => { + entry.lastClearedSqlHash = clearedSqlHash + }, + () => undefined, + ) + } + + private dropPendingSnapshot(entry: Entry) { + entry.pendingSnapshot = null + if (entry.snapshotTimer) { + clearTimeout(entry.snapshotTimer) + entry.snapshotTimer = null + } + } + + private setState(entry: Entry, patch: Partial) { + entry.state = { ...entry.state, ...patch } + this.notify(entry.cellId) + } + + private notify(cellId: string) { + this.listeners.notify(cellId) + } +} diff --git a/src/scenes/Editor/Notebook/cellVirtualization/GridShimmer.tsx b/src/scenes/Editor/Notebook/cellVirtualization/GridShimmer.tsx index 7bbe6cf81..c307ca714 100644 --- a/src/scenes/Editor/Notebook/cellVirtualization/GridShimmer.tsx +++ b/src/scenes/Editor/Notebook/cellVirtualization/GridShimmer.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from "react" import styled from "styled-components" import { color } from "../../../../utils" -import type { CellResult, SingleQueryResult } from "../../../../store/notebook" +import type { SingleQueryResult } from "../../../../store/notebook" import { CELL_BORDER_PX, CELL_PADDING_PX, @@ -199,18 +199,6 @@ const valueWidthPct = ( return 45 + jitter } -const activeResultOf = (result: CellResult): SingleQueryResult | undefined => - result.results[result.activeResultIndex] ?? result.results[0] - -const tabKeysFor = (results: SingleQueryResult[]): string[] => { - const seen = new Map() - return results.map((r) => { - const count = seen.get(r.query) ?? 0 - seen.set(r.query, count + 1) - return `tab-${r.query}-${count}` - }) -} - // The live grid's width/order/pinning pipeline, so the swap shifts nothing. // Frozen columns follow the pin list, not columnOrder — ResultGrid's // moveColumnToFront reorders the pin list alone. @@ -313,42 +301,46 @@ const GenericGridShimmer = () => ( ) +// Geometry follows the DERIVED frame: the tab strip counts statements (a +// "Not run" slot still owns a tab), and the silhouette samples whatever the +// active slot currently shows. export const GridShimmer = ({ - result, + statementCount, + activeResult, bufferId, cellId, }: { - result?: CellResult + statementCount: number + activeResult?: SingleQueryResult bufferId: number cellId: string }) => { const { maxColumnWidth } = useLocalStorage() const fontsReady = useFontsReady() - const active = result ? activeResultOf(result) : undefined // fontsReady is a cache buster: the webfont landing invalidates every // measured width, so a placeholder sampled against the fallback re-samples // rather than holding stale widths for the life of the dataset. const columns = useMemo( - () => displayColumnsFor(active, bufferId, cellId, maxColumnWidth), - [active, bufferId, cellId, maxColumnWidth, fontsReady], + () => displayColumnsFor(activeResult, bufferId, cellId, maxColumnWidth), + [activeResult, bufferId, cellId, maxColumnWidth, fontsReady], ) const rowCount = - active?.type === "dql" - ? Math.min(active.dataset.length, MAX_SHIMMER_ROWS) + activeResult?.type === "dql" + ? Math.min(activeResult.dataset.length, MAX_SHIMMER_ROWS) : 0 return ( - {isChart && ( - <> - - - - - e.stopPropagation()} - aria-label={`Auto-refresh interval: ${autoRefreshLabel( - autoRefresh, - )}${hasOverride ? " (overrides notebook default)" : ""}`} - > - {hasOverride && } - {autoRefreshLabel(autoRefresh)} - - - - - - - - - - - + + {writeBlocked ? ( + + + Off + + + + ) : ( + + + + e.stopPropagation()} + aria-label={`Auto-refresh interval: ${autoRefreshLabel( + autoRefresh, + )}${hasOverride ? " (overrides notebook default)" : ""}`} + > + {hasOverride && } + {autoRefreshLabel(autoRefresh)} + + + + + + + + + + )} ) diff --git a/src/scenes/Editor/Notebook/cells/CellToolbar.tsx b/src/scenes/Editor/Notebook/cells/CellToolbar.tsx index 75b89ae78..6da09027b 100644 --- a/src/scenes/Editor/Notebook/cells/CellToolbar.tsx +++ b/src/scenes/Editor/Notebook/cells/CellToolbar.tsx @@ -32,6 +32,7 @@ import { import type { CellToolbarTier } from "../notebookUtils" import type { AutoRefresh, NotebookCell } from "../../../../store/notebook" import { useNotebookActions, useNotebookBufferId } from "../NotebookProvider" +import { useCellFetchState } from "../cellRefresh/CellRefreshContext" import { emitUserAction, signalUserEdit, @@ -131,6 +132,10 @@ export const CellToolbar: React.FC = ({ const isNoneView = view === "none" const isViewMaximized = !isNoneView && !!cell.isViewMaximized const autoRefresh = resolveAutoRefresh(cell.autoRefresh, autoRefreshDefault) + // A write cell never ticks, so the menu must not offer an interval the + // engine would ignore — same gate the inline selector applies. + const autoRefreshBlocked = + useCellFetchState(cellId)?.classifyBlock?.kind === "write" const [menuOpen, setMenuOpen] = useState(false) const moreActionsTooltip = useTriggerTooltip() @@ -375,8 +380,10 @@ export const CellToolbar: React.FC = ({ )} {showAutoRefreshItem && ( - - {`Auto-refresh (${autoRefreshLabel(autoRefresh)})`} + + {autoRefreshBlocked + ? "Auto-refresh (contains DDL/DML)" + : `Auto-refresh (${autoRefreshLabel(autoRefresh)})`} diff --git a/src/scenes/Editor/Notebook/cells/useCellRunActions.ts b/src/scenes/Editor/Notebook/cells/useCellRunActions.ts index 695230078..edf7b72d7 100644 --- a/src/scenes/Editor/Notebook/cells/useCellRunActions.ts +++ b/src/scenes/Editor/Notebook/cells/useCellRunActions.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from "react" import type { editor } from "monaco-editor" import type { NotebookCell } from "../../../../store/notebook" import { useNotebookActions, useNotebookBufferId } from "../NotebookProvider" +import { useCellRefresh } from "../cellRefresh/CellRefreshContext" import { useLocalStorage } from "../../../../providers/LocalStorageProvider" import { useValidateWithGlobals } from "../globals/useValidateWithGlobals" import { getQueryFromCursor, normalizeQueryText } from "../../Monaco/utils" @@ -247,9 +248,33 @@ export const useCellRunActions = ({ ) const runAll = useCallback(() => runResolved("all"), [runResolved]) const runSingle = useCallback(() => runResolved("single"), [runResolved]) - // Refresh re-runs the whole cell to reproduce its grid — never a stray - // editor selection. - const refreshRun = useCallback(() => runResolved("all", true), [runResolved]) + const cellRefresh = useCellRefresh() + // Refresh on a classified non-write grid routes to the engine: every + // statement refreshes in parallel while the old rows stay visible. A write + // cell — and a cell without a grid — keeps the run gesture (never a stray + // editor selection). + const refreshRun = useCallback(() => { + const state = cellRefresh?.getState(cell.id) + const engineRefreshable = + cellRefresh !== null && + cell.mode !== "draw" && + cell.result != null && + state !== undefined && + state.classifiedKey === state.queriesKey && + state.classifyBlock === null + if (engineRefreshable) { + void cellRefresh.refresh(cell.id).then(() => { + const settled = cellRefresh.getState(cell.id) + emitRanEvent( + settled !== undefined && settled.slotErrors.size > 0 + ? "error" + : "success", + ) + }) + return + } + runResolved("all", true) + }, [cell.id, cell.mode, cell.result, cellRefresh, emitRanEvent, runResolved]) useEffect(() => { if (!isRunning) firstRunRef.current = false diff --git a/src/scenes/Editor/Notebook/cells/useChartLoading.ts b/src/scenes/Editor/Notebook/cells/useChartLoading.ts index 91b10c784..10ed4e296 100644 --- a/src/scenes/Editor/Notebook/cells/useChartLoading.ts +++ b/src/scenes/Editor/Notebook/cells/useChartLoading.ts @@ -1,10 +1,10 @@ import { useEffect, useState } from "react" import type { CellResult, NotebookCell } from "../../../../store/notebook" -import { useChartRefresh } from "../chartRefresh/ChartRefreshContext" +import { useCellRefresh } from "../cellRefresh/CellRefreshContext" import { deriveChartLoading, - type ChartFetchState, -} from "../chartRefresh/chartRefreshEngine" + type CellFetchState, +} from "../cellRefresh/cellRefreshEngine" import { toChartResult } from "../DrawCanvas/drawCanvasUtils" import { useCellResultStatus } from "../resultHydration/CellResultHydrationContext" @@ -13,7 +13,7 @@ type ChartLoadingState = { loading: boolean; refreshing: boolean } const IDLE: ChartLoadingState = { loading: false, refreshing: false } const derive = ( - fetchState: ChartFetchState | undefined, + fetchState: CellFetchState | undefined, result: CellResult | null | undefined, resultLoading: boolean, ): ChartLoadingState => { @@ -31,7 +31,7 @@ const derive = ( // mid-fetch correct, and covers entry removal — getState turns undefined and // the state derives back to idle. export const useChartLoading = (cell: NotebookCell): ChartLoadingState => { - const engine = useChartRefresh() + const engine = useCellRefresh() const resultStatus = useCellResultStatus(cell.id) const resultLoading = resultStatus === "loading" const result = cell.result diff --git a/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.ts b/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.ts deleted file mode 100644 index cef268949..000000000 --- a/src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.ts +++ /dev/null @@ -1,856 +0,0 @@ -import type { QueryExecResult } from "../../../../hooks/useQueryExecution" -import { runAdaptivePollLoop } from "../../../../hooks/useAdaptivePoll" -import { sleep } from "../../../../utils/sleep" -import { createLimiter } from "../../../../utils/limiter" -import type { - AutoRefresh, - CellResult, - NotebookCell, - SingleQueryResult, -} from "../../../../store/notebook" -import type { ValidateQueryResult } from "../../../../utils/questdb/types" -import { eventBus } from "../../../../modules/EventBus" -import { EventType } from "../../../../modules/EventBus/types" -import { getQueriesFromText, normalizeQueryText } from "../../Monaco/utils" -import { - autoRefreshIntervalMs, - NOTEBOOK_ROW_CAP, - resolveAutoRefresh, - singleResultFromExec, - sqlHash, -} from "../notebookUtils" -import { - type ChartResult, - resultsEquivalent, - successResults, - toChartResult, - toExecResult, -} from "../DrawCanvas/drawCanvasUtils" -import type { CellResultStatus } from "../resultHydration/cellResultHydration" -import { deleteCellSnapshot } from "../../../../store/notebookResults" -import { persistCellSnapshot } from "../persistCellSnapshot" -import { PerKeyListeners } from "../perKeyListeners" - -const REFRESH_MIN_MS = 2000 -const REFRESH_MAX_MS = 60000 -const SQL_DEBOUNCE_MS = 300 -// Draw auto-refresh can poll every few seconds; throttle snapshot writes so a -// live chart doesn't churn IndexedDB. A reload restores the last saved frame. -const SNAPSHOT_THROTTLE_MS = 10000 -const MAX_CONCURRENT_FETCHES = 6 -const INITIAL_FETCH_JITTER_MS = 300 - -export type ChartClassifyBlock = - | { kind: "write"; queryType: string } - | { kind: "failed"; message: string } - -export type ChartFetchState = { - queries: string[] - queriesKey: string - fetching: boolean - settledKey: string | null - classifyBlock: ChartClassifyBlock | null -} - -export type ChartRefreshDeps = { - executeSingle: ( - sql: string, - signal?: AbortSignal, - limit?: number, - ) => Promise - validateWithGlobals: ( - sql: string, - signal?: AbortSignal, - ) => Promise - setCellResult: (cellId: string, result: CellResult | undefined) => void - getCellResult: (cellId: string) => CellResult | null | undefined - isDrawCell: (cellId: string) => boolean - resultLoadStatus: (cellId: string) => CellResultStatus - subscribeResultLoad: (cellId: string, listener: () => void) => () => void - requestResultLoad: (cellId: string) => void - noteResultMissing: (cellId: string) => void - onSnapshotPersisted: (cellId: string, results: SingleQueryResult[]) => void -} - -const QUERIES_KEY_SEPARATOR = "\u0001" - -const joinQueriesKey = (queries: string[]): string => - queries.join(QUERIES_KEY_SEPARATOR) - -const normalizedQueriesKey = (queriesKey: string): string => - queriesKey - .split(QUERIES_KEY_SEPARATOR) - .map(normalizeQueryText) - .join(QUERIES_KEY_SEPARATOR) - -export const pendingChartFetchState = (sql: string): ChartFetchState => { - const queries = getQueriesFromText(sql) - return { - queries, - queriesKey: joinQueriesKey(queries), - fetching: false, - settledKey: null, - classifyBlock: null, - } -} - -export const deriveChartLoading = ( - state: ChartFetchState, - chartResult: ChartResult, - resultLoading: boolean, -): { loading: boolean; refreshing: boolean } => { - const hasData = - chartResult.kind === "settled" && chartResult.results.length > 0 - const loading = - state.queries.length > 0 && - state.classifyBlock === null && - !hasData && - (state.settledKey !== state.queriesKey || - resultLoading || - (state.fetching && chartResult.kind !== "settled")) - return { loading, refreshing: state.fetching && !loading } -} - -const errorMessage = (cause: unknown): string => { - if (typeof cause === "string" && cause) return cause - if (cause instanceof Error && cause.message) return cause.message - return "Query failed" -} - -const errorExecResult = (query: string, cause: unknown): QueryExecResult => ({ - type: "error", - query, - columns: [], - dataset: [], - count: 0, - error: errorMessage(cause), -}) - -export type ChartRefreshEngineOptions = { - maxConcurrentFetches?: number - initialFetchJitterMs?: number -} - -type DrawCellSyncKey = Pick - -const sameDrawCells = ( - previous: DrawCellSyncKey[], - drawCells: NotebookCell[], -): boolean => - previous.length === drawCells.length && - drawCells.every( - (cell, index) => - previous[index].id === cell.id && - previous[index].value === cell.value && - previous[index].autoRefresh === cell.autoRefresh, - ) - -type Entry = { - cellId: string - sql: string - autoRefresh: AutoRefresh - visible: boolean - pendingManualRefresh: boolean - manualRefreshInFlight: boolean - ensureAttempted: boolean - lastFetchedAt: number - state: ChartFetchState - classifyCache: Map - sqlDebounce: ReturnType | null - pendingSql: string | null - inFlight: AbortController | null - poll: AbortController | null - pollKey: string | null - resultLoadUnsubscribe: (() => void) | null - lastSnapshotAt: number - persistedResults: WeakMap - lastClearedSqlHash: string | null - pendingSnapshot: { results: SingleQueryResult[]; durationMs: number } | null - snapshotTimer: ReturnType | null -} - -export class ChartRefreshEngine { - private entries = new Map() - private listeners = new PerKeyListeners() - private visibilityByCell = new Map() - private lastSyncedDrawCells: DrawCellSyncKey[] | null = null - private autoRefreshDefault: AutoRefresh | undefined - private documentHidden = false - private limitFetch: (task: () => Promise) => Promise - private initialFetchJitterMs: number - - private refreshHandler = (payload?: { cellId?: string }) => { - if (payload?.cellId) this.refresh(payload.cellId) - } - - private documentVisibilityHandler = () => { - const hidden = document.hidden - if (hidden === this.documentHidden) return - this.documentHidden = hidden - for (const entry of this.entries.values()) { - if (hidden) this.updatePoll(entry) - else if (entry.visible) this.resume(entry) - } - } - - constructor( - private bufferId: number, - private getDeps: () => ChartRefreshDeps, - options: ChartRefreshEngineOptions = {}, - ) { - this.limitFetch = createLimiter( - options.maxConcurrentFetches ?? MAX_CONCURRENT_FETCHES, - ) - this.initialFetchJitterMs = - options.initialFetchJitterMs ?? INITIAL_FETCH_JITTER_MS - } - - attach() { - eventBus.subscribe( - EventType.NOTEBOOK_CELL_REFRESH_CHART, - this.refreshHandler, - ) - if (typeof document !== "undefined") { - this.documentHidden = document.hidden - document.addEventListener( - "visibilitychange", - this.documentVisibilityHandler, - ) - } - } - - destroy() { - eventBus.unsubscribe( - EventType.NOTEBOOK_CELL_REFRESH_CHART, - this.refreshHandler, - ) - if (typeof document !== "undefined") { - document.removeEventListener( - "visibilitychange", - this.documentVisibilityHandler, - ) - } - for (const cellId of [...this.entries.keys()]) { - this.removeEntry(cellId, "teardown") - } - this.visibilityByCell.clear() - this.lastSyncedDrawCells = null - } - - sync(cells: NotebookCell[], autoRefreshDefault?: AutoRefresh) { - const drawCells = cells.filter((cell) => cell.mode === "draw") - const defaultChanged = autoRefreshDefault !== this.autoRefreshDefault - this.autoRefreshDefault = autoRefreshDefault - if ( - !defaultChanged && - this.lastSyncedDrawCells && - sameDrawCells(this.lastSyncedDrawCells, drawCells) - ) - return - this.lastSyncedDrawCells = drawCells.map(({ id, value, autoRefresh }) => ({ - id, - value, - autoRefresh, - })) - const present = new Set() - for (const cell of drawCells) { - present.add(cell.id) - const entry = this.entries.get(cell.id) - if (entry) this.updateEntry(entry, cell) - else this.createEntry(cell) - } - const cellIds = new Set(cells.map((cell) => cell.id)) - for (const cellId of [...this.entries.keys()]) { - if (!present.has(cellId)) { - this.removeEntry( - cellId, - cellIds.has(cellId) ? "modeExited" : "cellDeleted", - ) - } - } - // Visibility follows the CELL, not the entry: a chart→grid→chart toggle - // recreates the entry while the cell never leaves the viewport, so the - // observer won't re-report it. Only a deleted cell drops its record. - for (const cellId of [...this.visibilityByCell.keys()]) { - if (!cellIds.has(cellId)) this.visibilityByCell.delete(cellId) - } - } - - refresh(cellId: string) { - const entry = this.entries.get(cellId) - if (entry) void this.fetchOnce(entry) - } - - refreshAll() { - for (const entry of this.entries.values()) { - if (!entry.visible || this.documentHidden) { - entry.pendingManualRefresh = true - continue - } - // A repeat click must not abort the fetch the previous click started; a - // background poll fetch queried pre-click state, so supersede it. - if (entry.inFlight && entry.manualRefreshInFlight) continue - this.forceRefresh(entry) - } - } - - private forceRefresh(entry: Entry) { - if (!entry.visible || this.documentHidden) { - entry.pendingManualRefresh = true - return - } - entry.pendingManualRefresh = false - this.promotePendingSql(entry) - entry.manualRefreshInFlight = true - entry.inFlight?.abort() - entry.inFlight = null - if (this.shouldPoll(entry)) { - entry.lastFetchedAt = 0 - entry.poll?.abort() - entry.poll = null - entry.pollKey = null - this.updatePoll(entry) - } else { - void this.fetchOnce(entry) - } - } - - private promotePendingSql(entry: Entry) { - if (entry.sqlDebounce) { - clearTimeout(entry.sqlDebounce) - entry.sqlDebounce = null - } - const sql = entry.pendingSql - entry.pendingSql = null - if (sql == null || sql === entry.sql) return - this.applySqlState(entry, sql) - } - - // Called by the notebook's cell visibility observer. Hiding pauses the poll - // (in-flight fetches finish and land); revealing resumes it, fetching - // immediately when the data is older than the cell's interval. - setVisible(cellId: string, visible: boolean) { - this.visibilityByCell.set(cellId, visible) - const entry = this.entries.get(cellId) - if (!entry || entry.visible === visible) return - entry.visible = visible - if (visible) this.resume(entry) - else this.updatePoll(entry) - } - - setOnlyVisible(cellIds: string[]) { - const visible = new Set(cellIds) - for (const cellId of this.entries.keys()) { - if (!visible.has(cellId)) this.setVisible(cellId, false) - } - for (const cellId of cellIds) this.setVisible(cellId, true) - } - - requestHydrate(cellId: string) { - const entry = this.entries.get(cellId) - if (!entry || entry.ensureAttempted) return - this.ensureData(entry) - } - - private resume(entry: Entry) { - if (entry.pendingManualRefresh) { - this.forceRefresh(entry) - return - } - this.ensureData(entry) - } - - getState(cellId: string): ChartFetchState | undefined { - return this.entries.get(cellId)?.state - } - - subscribe(cellId: string, listener: () => void): () => void { - return this.listeners.subscribe(cellId, listener) - } - - private createEntry(cell: NotebookCell) { - const entry: Entry = { - cellId: cell.id, - sql: cell.value, - autoRefresh: resolveAutoRefresh( - cell.autoRefresh, - this.autoRefreshDefault, - ), - state: pendingChartFetchState(cell.value), - visible: this.visibilityByCell.get(cell.id) ?? false, - pendingManualRefresh: false, - manualRefreshInFlight: false, - ensureAttempted: false, - lastFetchedAt: 0, - classifyCache: new Map(), - sqlDebounce: null, - pendingSql: null, - inFlight: null, - poll: null, - pollKey: null, - resultLoadUnsubscribe: null, - lastSnapshotAt: 0, - persistedResults: new WeakMap(), - lastClearedSqlHash: null, - pendingSnapshot: null, - snapshotTimer: null, - } - this.entries.set(cell.id, entry) - if (entry.visible) this.ensureData(entry) - } - - private updateEntry(entry: Entry, cell: NotebookCell) { - const autoRefresh = resolveAutoRefresh( - cell.autoRefresh, - this.autoRefreshDefault, - ) - if (autoRefresh !== entry.autoRefresh) { - entry.autoRefresh = autoRefresh - this.updatePoll(entry) - } - const target = entry.pendingSql ?? entry.sql - if (cell.value === target) return - entry.pendingSql = cell.value - if (entry.sqlDebounce) clearTimeout(entry.sqlDebounce) - entry.sqlDebounce = setTimeout(() => { - entry.sqlDebounce = null - const sql = entry.pendingSql - entry.pendingSql = null - if (sql != null && sql !== entry.sql) this.applySql(entry, sql) - }, SQL_DEBOUNCE_MS) - } - - private removeEntry( - cellId: string, - reason: "cellDeleted" | "modeExited" | "teardown", - ) { - const entry = this.entries.get(cellId) - if (!entry) return - if (entry.sqlDebounce) clearTimeout(entry.sqlDebounce) - const pending = entry.pendingSnapshot - this.dropPendingSnapshot(entry) - if (reason === "teardown" && pending) { - entry.lastSnapshotAt = 0 - this.queueSnapshot(entry, pending.results, pending.durationMs) - } - this.stopResultLoadWait(entry) - entry.inFlight?.abort() - entry.inFlight = null - entry.poll?.abort() - this.entries.delete(cellId) - // Subscribers re-derive from the now-missing state and settle on idle, so - // the toolbar is not stranded spinning after the entry is gone. - this.notify(cellId) - } - - private applySqlState(entry: Entry, sql: string) { - entry.inFlight?.abort() - entry.inFlight = null - entry.manualRefreshInFlight = false - this.stopResultLoadWait(entry) - entry.sql = sql - entry.classifyCache = new Map() - entry.lastFetchedAt = 0 - // The snapshot throttle window belongs to the previous SQL, and so does a - // pending frame it blocked — the next save must not inherit either. - entry.lastSnapshotAt = 0 - this.dropPendingSnapshot(entry) - const queries = getQueriesFromText(sql) - const queriesKey = joinQueriesKey(queries) - const sameQueries = - entry.state.settledKey !== null && - normalizedQueriesKey(entry.state.settledKey) === - normalizedQueriesKey(queriesKey) - this.setState(entry, { - queries, - queriesKey, - fetching: false, - ...(sameQueries ? { settledKey: queriesKey } : {}), - }) - } - - private applySql(entry: Entry, sql: string) { - this.applySqlState(entry, sql) - if (entry.visible) this.ensureData(entry) - else entry.ensureAttempted = false - } - - // Settle from the data already in cell.result — the just-run grid, the - // engine's own last frame, or a snapshot the hydration engine restored — - // instead of re-querying. Waits for an in-flight snapshot load; falls back - // to a live fetch when nothing usable exists for the CURRENT queries. - // - // Never runs for a cell outside the bands: creation and applySql defer it - // until the retain band (requestHydrate) or a reveal (resume) asks — the - // same mount/retain contract run-cell results follow. - private ensureData(entry: Entry) { - entry.ensureAttempted = true - this.stopResultLoadWait(entry) - const { queries, queriesKey, settledKey, classifyBlock } = entry.state - if (queries.length === 0) { - if (settledKey !== queriesKey) void this.fetchOnce(entry) - this.updatePoll(entry) - return - } - if (classifyBlock !== null && settledKey === queriesKey) { - this.updatePoll(entry) - return - } - const chartResult = toChartResult( - this.getDeps().getCellResult(entry.cellId), - queries, - ) - if ( - chartResult.kind === "settled" && - (chartResult.results.length > 0 || settledKey === queriesKey) - ) { - this.setState(entry, { settledKey: queriesKey }) - entry.lastFetchedAt = chartResult.timestamp - this.updatePoll(entry) - return - } - if (this.getDeps().resultLoadStatus(entry.cellId) === "unrequested") { - this.getDeps().requestResultLoad(entry.cellId) - } - if (this.getDeps().resultLoadStatus(entry.cellId) === "loading") { - entry.resultLoadUnsubscribe = this.getDeps().subscribeResultLoad( - entry.cellId, - () => { - const status = this.getDeps().resultLoadStatus(entry.cellId) - if ( - status !== "loaded" && - status !== "missing" && - status !== "failed" - ) - return - this.stopResultLoadWait(entry) - this.ensureData(entry) - }, - ) - this.updatePoll(entry) - return - } - if (this.shouldPoll(entry)) { - // No usable data at this point — a poll still sleeping on a pre-release - // lastFetchedAt must not defer the refetch, so restart the loop with an - // immediate first tick. - entry.lastFetchedAt = 0 - entry.poll?.abort() - entry.poll = null - entry.pollKey = null - this.updatePoll(entry) - return - } - if (entry.visible && !this.documentHidden) void this.fetchOnce(entry) - this.updatePoll(entry) - } - - private stopResultLoadWait(entry: Entry) { - entry.resultLoadUnsubscribe?.() - entry.resultLoadUnsubscribe = null - } - - private shouldAutoRefresh(entry: Entry): boolean { - return entry.autoRefresh !== false && entry.state.queries.length > 0 - } - - private shouldPoll(entry: Entry): boolean { - return ( - this.shouldAutoRefresh(entry) && entry.visible && !this.documentHidden - ) - } - - private updatePoll(entry: Entry) { - const enabled = this.shouldPoll(entry) - const key = enabled - ? `${entry.state.queriesKey}\u0001${String(entry.autoRefresh)}` - : null - if (entry.pollKey === key) return - entry.poll?.abort() - entry.poll = null - entry.pollKey = key - if (!enabled) { - // A manual refresh still waiting out the start jitter dies with the - // poll — hand it back to pending so resume() redeems the click. - if (entry.manualRefreshInFlight && !entry.inFlight) { - entry.manualRefreshInFlight = false - entry.pendingManualRefresh = true - } - return - } - const abort = new AbortController() - entry.poll = abort - void this.runPollLoop(entry, abort) - } - - // The jitter offsets each loop's start so charts starting together don't tick together. - private async runPollLoop(entry: Entry, abort: AbortController) { - if (this.initialFetchJitterMs > 0) { - const jitter = Math.random() * this.initialFetchJitterMs - const aborted = await sleep(jitter, abort.signal) - if (aborted) return - } - const fixed = autoRefreshIntervalMs(entry.autoRefresh) - const skipInitialFetch = - Date.now() - entry.lastFetchedAt < (fixed ?? REFRESH_MIN_MS) - await runAdaptivePollLoop({ - fetchFn: () => this.fetchOnce(entry), - signal: abort.signal, - minIntervalMs: fixed ?? REFRESH_MIN_MS, - maxIntervalMs: fixed ?? REFRESH_MAX_MS, - skipInitialFetch, - }) - } - - private async fetchOnce(entry: Entry): Promise { - // Supersede any in-flight fetch up front, so a slow earlier response can't - // land after the query changed — including when it's cleared to empty. - entry.inFlight?.abort() - entry.inFlight = null - this.stopResultLoadWait(entry) - const { queries, queriesKey } = entry.state - if (queries.length === 0) { - entry.manualRefreshInFlight = false - this.setState(entry, { - fetching: false, - settledKey: queriesKey, - classifyBlock: null, - }) - this.clearCellData(entry) - return - } - const ac = new AbortController() - entry.inFlight = ac - this.setState(entry, { fetching: true }) - return this.limitFetch(async () => { - if (ac.signal.aborted) return - const start = performance.now() - await this.runFetch(entry, ac) - return performance.now() - start - }) - } - - private async runFetch(entry: Entry, ac: AbortController) { - const deps = this.getDeps() - const { queries, queriesKey } = entry.state - try { - // Runtime backstop: a user typing DDL into an already-draw cell would - // otherwise reach executeSingle on the next poll tick. A query failing - // validation is neither cached nor executed — re-validating it every - // tick means an INSERT whose missing table appears later classifies as - // a write and gets blocked, instead of silently running. - const validationErrors = new Map() - try { - await Promise.all( - queries.map(async (q) => { - if (entry.classifyCache.has(q)) return - const res = await deps.validateWithGlobals(q, ac.signal) - if ("error" in res) validationErrors.set(q, res.error) - else if ("columns" in res) entry.classifyCache.set(q, "DQL") - else entry.classifyCache.set(q, "DDL_DML") - }), - ) - } catch (e) { - if (ac.signal.aborted) return - const message = e instanceof Error ? e.message : "validate failed" - this.setState(entry, { - classifyBlock: { kind: "failed", message }, - settledKey: queriesKey, - }) - return - } - if (ac.signal.aborted || !deps.isDrawCell(entry.cellId)) return - const offender = queries - .map((q) => ({ q, klass: entry.classifyCache.get(q) })) - .find((x) => x.klass === "DDL_DML") - if (offender) { - const validateResult = await deps - .validateWithGlobals(offender.q, ac.signal) - .catch(() => null) - if (ac.signal.aborted) return - const queryType = - validateResult && "queryType" in validateResult - ? validateResult.queryType - : "write" - this.setState(entry, { - classifyBlock: { kind: "write", queryType }, - settledKey: queriesKey, - }) - // The cell now holds a write — drop any stale rows the grid would show. - this.clearCellData(entry) - return - } - if (entry.state.classifyBlock !== null) { - this.setState(entry, { classifyBlock: null }) - } - const fetchStartedAt = Date.now() - const out = await Promise.all( - queries.map((q) => { - const validationError = validationErrors.get(q) - if (validationError !== undefined) - return Promise.resolve(errorExecResult(q, validationError)) - return deps - .executeSingle(q, ac.signal, NOTEBOOK_ROW_CAP) - .catch((e) => errorExecResult(q, e)) - }), - ) - const fetchDurationMs = Date.now() - fetchStartedAt - if (ac.signal.aborted || !deps.isDrawCell(entry.cellId)) return - // Compare against the CURRENT cell.result, not a retained copy — the - // hydration engine may have released or replaced it since the last tick, - // and an unchanged frame must still be re-written in that case. - const currentSqlHash = sqlHash(entry.sql) - const current = this.getDeps().getCellResult(entry.cellId) - if ( - current != null && - resultsEquivalent(current.results.map(toExecResult), out) - ) { - this.setState(entry, { settledKey: queriesKey }) - if (successResults(out).length === 0) { - this.clearSnapshot(entry) - return - } - const persisted = - entry.persistedResults.get(current.results) === currentSqlHash - if (!persisted) { - this.queueSnapshot(entry, current.results, fetchDurationMs) - } - return - } - // Write EVERY statement (not just chartable ones) so a switch to the grid - // shows the same tabs a real run would — including errors and empty - // results — instead of dropping them or leaving stale rows behind. The - // result lands before settledKey flips: React 17 renders the two updates - // separately, and a settled state without data would flash "No data". - const written = out.map((r) => singleResultFromExec(r, r.query)) - this.getDeps().setCellResult(entry.cellId, { - results: written, - activeResultIndex: 0, - timestamp: Date.now(), - }) - this.setState(entry, { settledKey: queriesKey }) - if (successResults(out).length > 0) { - this.queueSnapshot(entry, written, fetchDurationMs) - } else { - this.clearSnapshot(entry) - } - } finally { - // Only clear when still the active fetch — a superseded (aborted) run - // must not flip `fetching` off while its replacement is in flight. - if (entry.inFlight === ac) { - entry.inFlight = null - entry.manualRefreshInFlight = false - entry.lastFetchedAt = Date.now() - this.setState(entry, { fetching: false }) - } - } - } - - private clearCellData(entry: Entry) { - this.getDeps().setCellResult(entry.cellId, undefined) - this.getDeps().noteResultMissing(entry.cellId) - this.clearSnapshot(entry) - } - - // Persist a throttled copy of the chart's frame — shared with run mode (one - // snapshot per cell) so the chart survives reload without re-fetch. A frame - // blocked by the throttle is kept pending and saved when the window reopens, - // so the final frame persists even when polling stops before the next tick. - private queueSnapshot( - entry: Entry, - results: SingleQueryResult[], - durationMs: number, - ) { - const now = Date.now() - const throttledForMs = SNAPSHOT_THROTTLE_MS - (now - entry.lastSnapshotAt) - if (throttledForMs > 0) { - entry.pendingSnapshot = { results, durationMs } - if (!entry.snapshotTimer) { - entry.snapshotTimer = setTimeout(() => { - entry.snapshotTimer = null - const pending = entry.pendingSnapshot - entry.pendingSnapshot = null - if (pending && this.entries.get(entry.cellId) === entry) { - this.queueSnapshot(entry, pending.results, pending.durationMs) - } - }, throttledForMs) - } - return - } - entry.lastSnapshotAt = now - void this.persistSnapshot(entry, results, durationMs) - } - - private persistSnapshot( - entry: Entry, - results: SingleQueryResult[], - durationMs: number, - ): Promise { - this.dropPendingSnapshot(entry) - const persistedSqlHash = sqlHash(entry.sql) - const failedCount = results.filter((r) => r.type === "error").length - return persistCellSnapshot({ - bufferId: this.bufferId, - cellId: entry.cellId, - results, - savedAt: Date.now(), - activeResultIndex: 0, - ...(results.length > 1 - ? { - script: { - successCount: results.length - failedCount, - failedCount, - durationMs, - }, - } - : {}), - }).then((saved) => { - if (!saved) return - entry.persistedResults.set(results, persistedSqlHash) - entry.lastClearedSqlHash = null - this.getDeps().onSnapshotPersisted(entry.cellId, results) - }) - } - - async flushPendingSnapshots(): Promise { - const writes: Promise[] = [] - for (const entry of this.entries.values()) { - const pending = entry.pendingSnapshot - if (!pending) continue - entry.lastSnapshotAt = Date.now() - writes.push( - this.persistSnapshot(entry, pending.results, pending.durationMs), - ) - } - await Promise.all(writes) - } - - private clearSnapshot(entry: Entry) { - this.dropPendingSnapshot(entry) - const clearedSqlHash = sqlHash(entry.sql) - if (entry.lastClearedSqlHash === clearedSqlHash) return - void deleteCellSnapshot(this.bufferId, entry.cellId).then( - () => { - entry.lastClearedSqlHash = clearedSqlHash - }, - () => undefined, - ) - } - - private dropPendingSnapshot(entry: Entry) { - entry.pendingSnapshot = null - if (entry.snapshotTimer) { - clearTimeout(entry.snapshotTimer) - entry.snapshotTimer = null - } - } - - private setState(entry: Entry, patch: Partial) { - entry.state = { ...entry.state, ...patch } - this.notify(entry.cellId) - } - - private notify(cellId: string) { - this.listeners.notify(cellId) - } -} diff --git a/src/scenes/Editor/Notebook/index.tsx b/src/scenes/Editor/Notebook/index.tsx index 4b27789e4..e304f2cc2 100644 --- a/src/scenes/Editor/Notebook/index.tsx +++ b/src/scenes/Editor/Notebook/index.tsx @@ -58,7 +58,7 @@ import { trackEvent } from "../../../modules/ConsoleEventTracker" import { ConsoleEvent } from "../../../modules/ConsoleEventTracker/events" import { consumeReveal, getPendingReveal } from "./cellReveal" import { useCellBandObservers } from "./useCellBandObservers" -import { useChartRefresh } from "./chartRefresh/ChartRefreshContext" +import { useCellRefresh } from "./cellRefresh/CellRefreshContext" import { useCellVirtualizationEngine } from "./cellVirtualization/CellVirtualizationContext" import { useCellResultHydrationEngine, @@ -750,7 +750,7 @@ const useNotebookSearchReveal = () => { const NotebookContent: React.FC = () => { const { cells, settings, focusedCellId, maximizedCellId, runningCellIds } = useNotebookState() - const chartEngine = useChartRefresh() + const chartEngine = useCellRefresh() const virtualizationEngine = useCellVirtualizationEngine() const layoutMode = settings.layoutMode ?? "list" useScrollRestoredCellIntoView(maximizedCellId) diff --git a/src/scenes/Editor/Notebook/notebookUtils.test.ts b/src/scenes/Editor/Notebook/notebookUtils.test.ts index 2a2e20e4c..33fe2deff 100644 --- a/src/scenes/Editor/Notebook/notebookUtils.test.ts +++ b/src/scenes/Editor/Notebook/notebookUtils.test.ts @@ -47,6 +47,11 @@ import { partitionCellHeights, topHeightForSql, patchCellRunResult, + reconcileCellResultForValue, + reconcileResultsForStatements, + derivePositionalFrame, + deriveStatementFrame, + statementKeysFor, removeCell, resolveRunCompletion, scaleCellHeights, @@ -836,19 +841,20 @@ describe("buildAppliedCells", () => { expect(resultsCleared).toEqual(["a"]) }) - it("reports resultsCleared for a released cell whose result lives only on disk", () => { + it("keeps a released cell's snapshot on a value change — hydration reconciles it", () => { // Given a cell released by virtualization: no in-memory result, only a run // marker pointing at a persisted snapshot const prev: NotebookCell[] = [ { id: "a", position: 0, value: "x", lastRunStatus: "success" }, ] // When an apply replaces its SQL - const { resultsCleared } = buildAppliedCells(prev, { + const { nextCells, resultsCleared } = buildAppliedCells(prev, { cells: [{ id: "a", value: "x2" }], }) - // Then the orphaned snapshot is flagged — hydration would otherwise - // resurrect the old SQL's rows under the new SQL - expect(resultsCleared).toEqual(["a"]) + // Then the snapshot survives for hydration to reconcile by statement + // content — unmatched results are dropped at load, never shown + expect(resultsCleared).toEqual([]) + expect(nextCells[0].lastRunStatus).toBe("success") }) it("reports resultsCleared when a run cell is converted to markdown", () => { @@ -887,7 +893,7 @@ describe("buildAppliedCells", () => { expect(nextCells[0].lastRunStatus).toBe("success") }) - it("resets lastRunStatus when a value change drops an error result", () => { + it("carries the error outcome as recorded history when a value change drops an error result", () => { // Given a cell that errored on its previous SQL const prev: NotebookCell[] = [ { @@ -903,17 +909,19 @@ describe("buildAppliedCells", () => { }, }, ] - // When the SQL is fixed via apply_notebook_state + // When the SQL is rewritten via apply_notebook_state const { nextCells } = buildAppliedCells(prev, { cells: [{ id: "a", value: "SELECT * FROM fx_trades" }], }) - // Then the stale error must not leak onto the fixed, not-yet-rerun cell + // Then the record still describes the last run that actually happened — + // only a new run rewrites it expect(nextCells[0].result).toBeNull() - expect(nextCells[0].lastRunStatus).toBeUndefined() + expect(nextCells[0].lastRunStatus).toBe("error") + expect(nextCells[0].lastRunError).toBe("boom") }) - it("resets a persisted error status when the SQL changes", () => { - // Given a passive cell whose error survives only as persisted run history + it("keeps a released cell's persisted run history when the SQL changes", () => { + // Given a passive cell whose outcome survives only as persisted run history const prev: NotebookCell[] = [ { id: "a", @@ -923,13 +931,14 @@ describe("buildAppliedCells", () => { }, ] - // When the SQL is fixed without a live result blob + // When the SQL is edited without a live result blob const { nextCells } = buildAppliedCells(prev, { cells: [{ id: "a", value: "SELECT * FROM fx_trades" }], }) - // Then the previous SQL's error is cleared - expect(nextCells[0].lastRunStatus).toBeUndefined() + // Then the recorded history stays — hydration reconciles the snapshot, + // and only a run rewrites the recorded outcome + expect(nextCells[0].lastRunStatus).toBe("error") }) it("preserveValue keeps the existing cell's value, result, and run history", () => { @@ -2217,7 +2226,7 @@ describe("lastRunError carry chain", () => { }) }) - it("buildAppliedCells drops the stale carried error when the value changes", () => { + it("buildAppliedCells keeps a stripped cell's carried error across a value change", () => { // Given a stripped errored cell carrying status + error markers const [stripped] = stripCellResults([errored("a")]) @@ -2226,9 +2235,10 @@ describe("lastRunError carry chain", () => { cells: [{ id: "a", value: "select fixed" }], }) - // Then neither the stale status nor its error attaches to the new SQL - expect(nextCells[0].lastRunStatus).toBeUndefined() - expect(nextCells[0].lastRunError).toBeUndefined() + // Then the recorded history survives — it describes the last run that + // actually happened, and only a run rewrites it + expect(nextCells[0].lastRunStatus).toBe("error") + expect(nextCells[0].lastRunError).toBe("table does not exist") }) it("buildAppliedCells drops run markers when a cell turns markdown", () => { @@ -2640,10 +2650,10 @@ describe("autoRefreshIntervalMs", () => { }) describe("auto-refresh inheritance helpers", () => { - it("resolveAutoRefresh prefers the override, then the default, then adaptive", () => { + it("resolveAutoRefresh prefers the override, then the default, then Off", () => { expect(resolveAutoRefresh("5s", "30s")).toBe("5s") expect(resolveAutoRefresh(undefined, "30s")).toBe("30s") - expect(resolveAutoRefresh(undefined, undefined)).toBe(true) + expect(resolveAutoRefresh(undefined, undefined)).toBe(false) }) it("resolveAutoRefresh treats false as a value, never as absent", () => { @@ -2664,17 +2674,30 @@ describe("auto-refresh inheritance helpers", () => { expect(isAutoRefreshOverride(cells[2])).toBe(false) }) - it("countActiveAutoRefreshOverrides ignores dormant run-cell keys — only draw overrides show in the toolbar", () => { - // Given a draw override, a dormant run-mode override, and an inheriting cell + it("countActiveAutoRefreshOverrides counts overrides on cells showing a view — editor-only keys stay dormant", () => { + // Given a chart override, a grid-view run override, and an editor-only override + const gridResult: NotebookCell["result"] = { + results: [ + { + type: "dql", + query: "SELECT 2", + columns: [{ name: "x", type: "INT" }], + dataset: [[1]], + count: 1, + }, + ], + activeResultIndex: 0, + timestamp: 0, + } const cells: NotebookCell[] = [ { ...cell("a", "SELECT 1"), mode: "draw", autoRefresh: "5s" }, - { ...cell("b", "SELECT 2"), mode: "run", autoRefresh: false }, - cell("c", "SELECT 3"), + { ...cell("b", "SELECT 2", gridResult), mode: "run", autoRefresh: false }, + { ...cell("c", "SELECT 3"), mode: "run", autoRefresh: "1s" }, ] - // Then only the draw override counts toward the displayed total - expect(countActiveAutoRefreshOverrides(cells)).toBe(1) - // And a notebook with only dormant keys shows no override at all - expect(countActiveAutoRefreshOverrides([cells[1], cells[2]])).toBe(0) + // Then only the cells with a visible view count toward the displayed total + expect(countActiveAutoRefreshOverrides(cells)).toBe(2) + // And a notebook with only editor-only keys shows no override at all + expect(countActiveAutoRefreshOverrides([cells[2]])).toBe(0) }) it("clearCellAutoRefresh deletes the key so a later draw switch cannot resurrect it", () => { @@ -2918,6 +2941,37 @@ describe("cellToolbarMenuFlags", () => { expect(f.showViewChart).toBe(true) }) + it("offers the interval submenu to grids, not just charts, wherever the inline control is absent", () => { + // Given grid cells in the tiers that render no inline interval control + // Then the menu is the fallback — auto-refresh is not a chart-only feature + expect(flags({ tier: "compact", view: "grid" }).showAutoRefreshItem).toBe( + true, + ) + expect(flags({ tier: "standard", view: "grid" }).showAutoRefreshItem).toBe( + true, + ) + }) + + it("never duplicates the inline interval control at the expanded tier", () => { + // Given the expanded tier, which renders the split-button interval for + // BOTH views + // Then the menu omits it rather than showing the same control twice + expect(flags({ tier: "expanded", view: "grid" }).showAutoRefreshItem).toBe( + false, + ) + expect(flags({ tier: "expanded", view: "chart" }).showAutoRefreshItem).toBe( + false, + ) + }) + + it("omits the interval submenu for a cell with no view", () => { + // Given a cell showing neither a grid nor a chart, there is nothing to + // auto-refresh + expect(flags({ tier: "compact", view: "none" }).showAutoRefreshItem).toBe( + false, + ) + }) + it("offers Reset zoom only for a zoomed chart in the compact tier", () => { // Given a zoomed chart: the wider tiers expose Reset zoom inline instead expect( @@ -3416,3 +3470,281 @@ describe("topHeight stamping", () => { expect(nextCells[0].topHeight).toBe(300) }) }) + +const dqlResult = (query: string, count = 1): SingleQueryResult => ({ + type: "dql", + query, + columns: [{ name: "x", type: "INT" }], + dataset: [[count]], + count, +}) + +const resultOf = ( + results: SingleQueryResult[], + extra: Partial = {}, +): CellResult => ({ + results, + activeResultIndex: 0, + timestamp: 0, + ...extra, +}) + +describe("reconcileResultsForStatements — content carryover", () => { + it("keeps results for unchanged statements across whitespace and semicolon edits", () => { + // Given a two-statement frame + const previous = resultOf([dqlResult("SELECT 1"), dqlResult("SELECT 2")]) + // When the statements only gain whitespace and semicolons + const reconciled = reconcileResultsForStatements( + [" SELECT 1;", "SELECT 2 "], + previous, + ) + // Then both results survive in statement order + expect(reconciled?.results.map((r) => r.query)).toEqual([ + "SELECT 1", + "SELECT 2", + ]) + }) + + it("drops an edited statement's result and keeps its siblings", () => { + // Given results for two statements + const previous = resultOf([dqlResult("SELECT 1"), dqlResult("SELECT 2")]) + // When the second statement is edited + const reconciled = reconcileResultsForStatements( + ["SELECT 1", "SELECT 999"], + previous, + ) + // Then only the untouched statement keeps its result + expect(reconciled?.results.map((r) => r.query)).toEqual(["SELECT 1"]) + }) + + it("matches duplicate statements by occurrence order", () => { + // Given two identical statements with distinct results + const previous = resultOf([ + dqlResult("SELECT 1", 10), + dqlResult("SELECT 1", 20), + ]) + // When one duplicate is removed + const reconciled = reconcileResultsForStatements(["SELECT 1"], previous) + // Then the first occurrence's result survives + expect(reconciled?.results).toHaveLength(1) + expect(reconciled?.results[0]).toMatchObject({ count: 10 }) + }) + + it("returns null when no result survives (the cell collapses)", () => { + // Given a frame whose only statement is rewritten + const previous = resultOf([dqlResult("SELECT 1")]) + // When reconciled against entirely new SQL + // Then there is no frame + expect(reconcileResultsForStatements(["SELECT 2"], previous)).toBeNull() + expect(reconcileResultsForStatements([], previous)).toBeNull() + }) + + it("preserves the active statement by content across a reorder", () => { + // Given the second statement is active + const previous = resultOf([dqlResult("SELECT 1"), dqlResult("SELECT 2")], { + activeResultIndex: 1, + activeStatementKey: statementKeysFor(["SELECT 2"])[0], + }) + // When the statements are reordered + const reconciled = reconcileResultsForStatements( + ["SELECT 2", "SELECT 1"], + previous, + ) + // Then the active key still points at the same statement + expect(reconciled?.activeStatementKey).toBe( + statementKeysFor(["SELECT 2"])[0], + ) + }) + + it("falls back to the nearest surviving statement when the active one is removed", () => { + // Given three results with the middle one active + const previous = resultOf( + [dqlResult("SELECT 1"), dqlResult("SELECT 2"), dqlResult("SELECT 3")], + { activeResultIndex: 1 }, + ) + // When the active statement is removed + const reconciled = reconcileResultsForStatements( + ["SELECT 1", "SELECT 3"], + previous, + ) + // Then the previous neighbor becomes active + expect(reconciled?.activeStatementKey).toBe( + statementKeysFor(["SELECT 1"])[0], + ) + }) + + it("anchors on activeResultIndex for records without an active key", () => { + // Given a legacy record with only a positional active index + const previous = resultOf([dqlResult("SELECT 1"), dqlResult("SELECT 2")], { + activeResultIndex: 1, + }) + // When the statement list is unchanged + const reconciled = reconcileResultsForStatements( + ["SELECT 1", "SELECT 2"], + previous, + ) + // Then the active key resolves to the indexed statement + expect(reconciled?.activeStatementKey).toBe( + statementKeysFor(["SELECT 2"])[0], + ) + }) + + it("never carries a running or queued placeholder as a result", () => { + // Given a frame a crash left behind: one settled result, one placeholder + const previous = resultOf([ + dqlResult("SELECT 1"), + { type: "running", query: "SELECT 2" }, + ]) + // When reconciled against the unchanged statements + const reconciled = reconcileResultsForStatements( + ["SELECT 1", "SELECT 2"], + previous, + ) + // Then only the settled result survives — the placeholder slot + // regenerates as "Not run" at display time, never as a ghost spinner + expect(reconciled?.results.map((r) => r.query)).toEqual(["SELECT 1"]) + }) + + it("returns null for a frame holding only placeholders", () => { + // Given a snapshot that captured a run mid-flight + const previous = resultOf([ + { type: "running", query: "SELECT 1" }, + { type: "queued", query: "SELECT 2" }, + ]) + // When reconciled + // Then no result survives and the frame collapses + expect( + reconcileResultsForStatements(["SELECT 1", "SELECT 2"], previous), + ).toBeNull() + }) +}) + +describe("reconcileCellResultForValue — run ownership", () => { + it("returns a pending frame untouched — the run owns it", () => { + // Given a run in flight: one statement settled, one still running + const pending = resultOf([ + dqlResult("SELECT 1"), + { type: "running", query: "SELECT 2" }, + ]) + + // When the SQL is edited mid-run + const reconciled = reconcileCellResultForValue(pending, "SELECT 1") + + // Then the frame comes back as the SAME object: the run's positional + // writes stay aligned, and the carryover applies after the run settles + expect(reconciled).toBe(pending) + }) + + it("reconciles a settled frame by content", () => { + // Given a settled two-statement frame + const settled = resultOf([dqlResult("SELECT 1"), dqlResult("SELECT 2")]) + + // When the second statement is edited away + const reconciled = reconcileCellResultForValue(settled, "SELECT 1") + + // Then only the surviving statement keeps its result + expect(reconciled?.results.map((r) => r.query)).toEqual(["SELECT 1"]) + }) +}) + +describe("deriveStatementFrame — display slots", () => { + it("gives every statement a slot and marks resultless slots as not run", () => { + // Given a compact result missing the middle statement + const result = resultOf([dqlResult("SELECT 1"), dqlResult("SELECT 3")]) + // When the frame is derived for three statements + const frame = deriveStatementFrame( + ["SELECT 1", "SELECT 2", "SELECT 3"], + result, + ) + // Then slots follow editor order and the unmatched slot is empty + expect(frame?.slots.map((s) => s.result?.query ?? null)).toEqual([ + "SELECT 1", + null, + "SELECT 3", + ]) + }) + + it("resolves the active slot from the active statement key", () => { + // Given the frame's active key points at the last statement + const result = resultOf([dqlResult("SELECT 1"), dqlResult("SELECT 2")], { + activeStatementKey: statementKeysFor(["SELECT 2"])[0], + }) + // When the frame is derived with a placeholder in between + const frame = deriveStatementFrame( + ["SELECT 1", "SELECT 99", "SELECT 2"], + result, + ) + // Then the active slot index follows the statement, not the result index + expect(frame?.activeSlotIndex).toBe(2) + }) + + it("maps a legacy active index through the result's own statement", () => { + // Given a legacy record whose second result is active + const result = resultOf([dqlResult("SELECT 1"), dqlResult("SELECT 2")], { + activeResultIndex: 1, + }) + // When a new statement is inserted before it + const frame = deriveStatementFrame( + ["SELECT 0", "SELECT 1", "SELECT 2"], + result, + ) + // Then the active slot follows the statement content + expect(frame?.activeSlotIndex).toBe(2) + }) + + it("distinguishes duplicate statements by occurrence", () => { + // Given two identical statements with distinct results + const result = resultOf([ + dqlResult("SELECT 1", 10), + dqlResult("SELECT 1", 20), + ]) + // When the frame is derived + const frame = deriveStatementFrame(["SELECT 1", "SELECT 1"], result) + // Then each slot keeps its own occurrence's result + expect(frame?.slots.map((s) => s.result)).toMatchObject([ + { count: 10 }, + { count: 20 }, + ]) + }) + + it("returns null without a result or without a single surviving slot", () => { + // Given no result, or a result that matches no statement + const result = resultOf([dqlResult("SELECT 1")]) + // When the frame is derived + // Then there is no frame + expect(deriveStatementFrame(["SELECT 1"], null)).toBeNull() + expect(deriveStatementFrame([], result)).toBeNull() + expect(deriveStatementFrame(["SELECT 2"], result)).toBeNull() + }) +}) + +describe("derivePositionalFrame — orphan results (selection runs)", () => { + it("builds tabs from the results themselves when no statement claims them", () => { + // Given a selection-fragment result no editor statement matches + const result = resultOf([dqlResult("SELECT 1")]) + // When the positional frame is derived + const frame = derivePositionalFrame(result) + // Then the fragment gets its own visible slot with its rows attached + expect(frame?.slots).toHaveLength(1) + expect(frame?.slots[0].sql).toBe("SELECT 1") + expect(frame?.slots[0].result).toBe(result.results[0]) + }) + + it("keeps the active tab and clamps an out-of-range index", () => { + // Given a two-result frame viewed on its second tab + const result = resultOf([dqlResult("SELECT 1"), dqlResult("SELECT 2")], { + activeResultIndex: 1, + }) + // Then the active slot follows the index, clamped when out of range + expect(derivePositionalFrame(result)?.activeSlotIndex).toBe(1) + expect( + derivePositionalFrame({ ...result, activeResultIndex: 9 }) + ?.activeSlotIndex, + ).toBe(1) + }) + + it("returns null without a result or with an empty one", () => { + expect(derivePositionalFrame(null)).toBeNull() + expect(derivePositionalFrame(resultOf([]))).toBeNull() + }) +}) diff --git a/src/scenes/Editor/Notebook/notebookUtils.ts b/src/scenes/Editor/Notebook/notebookUtils.ts index 30fad4680..ef2c39277 100644 --- a/src/scenes/Editor/Notebook/notebookUtils.ts +++ b/src/scenes/Editor/Notebook/notebookUtils.ts @@ -14,6 +14,7 @@ import type { } from "../../../store/notebook" import { AUTO_REFRESH_INTERVALS, + chartAutoRefreshStamp, createCell, MAX_NOTEBOOK_CELLS, MAX_CELL_LINES, @@ -54,10 +55,13 @@ export const isAutoRefresh = (value: unknown): value is AutoRefresh => (typeof value === "string" && Object.prototype.hasOwnProperty.call(AUTO_REFRESH_INTERVALS, value)) +// Terminal fallback is Off for every view: nothing polls unless the cell or +// the notebook says so. Charts stay live because entering draw mode stamps an +// explicit Auto onto the cell (chartAutoRefreshStamp). export const resolveAutoRefresh = ( cellValue: AutoRefresh | undefined, notebookDefault: AutoRefresh | undefined, -): AutoRefresh => cellValue ?? notebookDefault ?? true +): AutoRefresh => cellValue ?? notebookDefault ?? false export const isAutoRefreshOverride = ( cell: Pick, @@ -69,8 +73,9 @@ export const countAutoRefreshOverrides = (cells: NotebookCell[]): number => export const countActiveAutoRefreshOverrides = ( cells: NotebookCell[], ): number => - cells.filter((cell) => cell.mode === "draw" && isAutoRefreshOverride(cell)) - .length + cells.filter( + (cell) => resolveCellView(cell) !== "none" && isAutoRefreshOverride(cell), + ).length export const clearCellAutoRefresh = (cell: NotebookCell): NotebookCell => { if (!isAutoRefreshOverride(cell)) return cell @@ -184,7 +189,9 @@ export const cellToolbarMenuFlags = (params: { const isNoneView = view === "none" const hasToolbarSplit = tier !== "compact" && !isNoneView const hasToolbarRefresh = tier === "expanded" && !isNoneView - const hasToolbarInterval = tier === "expanded" && isChartView + // The inline interval control rides on the refresh split-button, which the + // expanded tier renders for grids as well as charts. + const hasToolbarInterval = hasToolbarRefresh const chartCollapsed = isCompact && isChartView && sqlShown const showViewSql = isCompact && !isNoneView && !isMarkdown && !sqlShown @@ -195,7 +202,10 @@ export const cellToolbarMenuFlags = (params: { const showSplitItem = !hasToolbarSplit && !isNoneView && !isCompact const showResetZoom = isCompact && isChartView && chartZoomed && !chartCollapsed - const showAutoRefreshItem = !hasToolbarInterval && isChartView + // Auto-refresh applies to any cell showing a view, not just charts. Unlike + // Refresh it survives a collapsed chart: it patches cell state rather than + // publishing to the unmounted canvas. + const showAutoRefreshItem = !hasToolbarInterval && !isNoneView const showRefreshItem = !hasToolbarRefresh && !isNoneView && !chartCollapsed const showChartSettings = isChartView && !chartCollapsed const showMoveUp = !isGridMode && cellIndex > 0 @@ -379,6 +389,10 @@ export type CellRunOutcome = { cellChanged?: boolean notStarted?: boolean resultCleared?: boolean + // Barrier decisions for gated (agent) runs: a permission denial or an + // auto-run write skip. Nothing executed when either is set. + denied?: string + skipped?: string // The result THIS run produced, set only when it committed. Consumers that // report the run's output must read this instead of cell.result — a draw // cell's auto-refresh replaces cell.result independently of the run. @@ -448,20 +462,31 @@ export const collapseResultToRunStatus = (result: CellResult): RunStatus => { // Run history must survive every path that drops the result blob (persist, // duplicate, clone) — agents read last_run_status to decide whether a cell -// still needs an explicit run_cell. -const carriedRunStatus = (cell: NotebookCell): RunStatus | undefined => - cell.result ? collapseResultToRunStatus(cell.result) : cell.lastRunStatus - -// The error string to carry alongside lastRunStatus when the result blob is -// dropped. Derived from the live result when present (so it always matches the -// carried status and a since-fixed cell clears its stale error), else the -// already-carried lastRunError. -const carriedRunError = (cell: NotebookCell): string | undefined => { - if (cell.result) { - const errored = cell.result.results.find((r) => r.type === "error") - return errored?.type === "error" ? errored.error : undefined +// still needs an explicit run_cell. Recorded history wins: every run commit +// stamps it, so it is at least as fresh as any run-produced result; only +// refresh results are newer, and excluding those is the point. +export const carriedRunStatus = (cell: NotebookCell): RunStatus | undefined => + cell.lastRunStatus ?? + (cell.result ? collapseResultToRunStatus(cell.result) : undefined) + +// The error travels with its recorded status as one pair; derivation from the +// result happens only for records that predate stamping. +export const carriedRunError = (cell: NotebookCell): string | undefined => { + if (cell.lastRunStatus !== undefined) return cell.lastRunError + const errored = cell.result?.results.find((r) => r.type === "error") + return errored?.type === "error" ? errored.error : undefined +} + +// The stamp a run commit writes next to its result. A run without an error +// clears any previous one — history describes the last run wholesale. +export const runHistoryPatch = ( + result: CellResult, +): Pick => { + const errored = result.results.find((r) => r.type === "error") + return { + lastRunStatus: collapseResultToRunStatus(result), + lastRunError: errored?.type === "error" ? errored.error : undefined, } - return cell.lastRunError } export const stripCellResults = (cells: NotebookCell[]): NotebookCell[] => @@ -724,6 +749,180 @@ export const snapshotResultsMatchQueries = ( normalizeQueryText(result.query) === normalizeQueryText(queries[index]), ) +// Statement identity across edits: normalized text plus occurrence order for +// duplicates. Results follow this key, never their position. +export type StatementKey = string + +const STATEMENT_KEY_SEPARATOR = "\u0001" + +export const statementKeysFor = (texts: string[]): StatementKey[] => { + const occurrences = new Map() + return texts.map((text) => { + const normalized = normalizeQueryText(text) + const occurrence = occurrences.get(normalized) ?? 0 + occurrences.set(normalized, occurrence + 1) + return `${normalized}${STATEMENT_KEY_SEPARATOR}${occurrence}` + }) +} + +const clampIndex = (index: number, length: number): number => + Math.min(Math.max(index, 0), Math.max(length - 1, 0)) + +const nearestCarriedKey = ( + newKeyByOldIndex: Map, + anchor: number, + length: number, +): StatementKey | undefined => { + for (let distance = 0; distance < length; distance++) { + const before = newKeyByOldIndex.get(anchor - distance) + if (before !== undefined) return before + const after = newKeyByOldIndex.get(anchor + distance) + if (after !== undefined) return after + } + return undefined +} + +export type ReconciledCellResult = { + results: SingleQueryResult[] + activeStatementKey: StatementKey + activeResultIndex: number +} + +export const reconcileResultsForStatements = ( + statements: string[], + previous: CellResult, +): ReconciledCellResult | null => { + if (statements.length === 0 || previous.results.length === 0) return null + const slotKeys = statementKeysFor(statements) + const resultKeys = statementKeysFor(previous.results.map((r) => r.query)) + const oldIndexByKey = new Map() + resultKeys.forEach((key, index) => oldIndexByKey.set(key, index)) + const survivors: SingleQueryResult[] = [] + const survivorKeys: StatementKey[] = [] + const newKeyByOldIndex = new Map() + for (const key of slotKeys) { + const oldIndex = oldIndexByKey.get(key) + if (oldIndex === undefined) continue + const candidate = previous.results[oldIndex] + // A placeholder is not a carryable result: carrying one would resurrect a + // ghost "Running" slot no execution backs (e.g. from a snapshot a crash + // left behind). The slot regenerates as "Not run" at display time. + if (candidate.type === "running" || candidate.type === "queued") continue + survivors.push(candidate) + survivorKeys.push(key) + newKeyByOldIndex.set(oldIndex, key) + } + if (survivors.length === 0) return null + const carriedActiveKey = + previous.activeStatementKey !== undefined && + slotKeys.includes(previous.activeStatementKey) + ? previous.activeStatementKey + : nearestCarriedKey( + newKeyByOldIndex, + clampIndex(previous.activeResultIndex, previous.results.length), + previous.results.length, + ) + const activeStatementKey = carriedActiveKey ?? slotKeys[0] + return { + results: survivors, + activeStatementKey, + activeResultIndex: Math.max(0, survivorKeys.indexOf(activeStatementKey)), + } +} + +// Applies the carryover to a cell's in-memory result after an SQL edit: +// unchanged statements keep their results, everything else drops. A frame +// that loses slots also loses its script summary — the counts no longer +// describe what is on screen. Zero survivors collapse the frame to null. +export const reconcileCellResultForValue = ( + result: CellResult | null | undefined, + value: string, +): CellResult | null => { + if (result == null) return null + // A pending frame is run-owned: the run writes results into it by position, + // so reshaping it here would land rows under the wrong statement. The frame + // stays pending until the run's last slot settles, and every completion step + // after that runs synchronously — deferring the reconcile is always safe. + if (hasPendingResult(result)) return result + const reconciled = reconcileResultsForStatements( + getQueriesFromText(value), + result, + ) + if (!reconciled) return null + const frameUnchanged = + reconciled.results.length === result.results.length && + reconciled.results.every((r, index) => r === result.results[index]) + const next: CellResult = { + ...result, + results: reconciled.results, + activeResultIndex: reconciled.activeResultIndex, + activeStatementKey: reconciled.activeStatementKey, + } + if (!frameUnchanged) delete next.script + return next +} + +export type StatementSlot = { + key: StatementKey + sql: string + result: SingleQueryResult | null +} + +export type StatementFrame = { + slots: StatementSlot[] + activeSlotIndex: number +} + +export const deriveStatementFrame = ( + statements: string[], + result: CellResult | null | undefined, +): StatementFrame | null => { + if (!result || statements.length === 0 || result.results.length === 0) { + return null + } + const slotKeys = statementKeysFor(statements) + const resultKeys = statementKeysFor(result.results.map((r) => r.query)) + const resultByKey = new Map() + resultKeys.forEach((key, index) => { + resultByKey.set(key, result.results[index]) + }) + const slots = slotKeys.map((key, index) => ({ + key, + sql: statements[index], + result: resultByKey.get(key) ?? null, + })) + if (slots.every((slot) => slot.result === null)) return null + const activeKey = + result.activeStatementKey ?? + resultKeys[clampIndex(result.activeResultIndex, resultKeys.length)] + const activeSlotIndex = slotKeys.indexOf(activeKey) + return { + slots, + activeSlotIndex: activeSlotIndex === -1 ? 0 : activeSlotIndex, + } +} + +// Fallback for a frame no statement claims: a selection or cursor-fragment +// run records the fragment it executed, so tabs follow the results +// themselves. Display-only — an edit or reload still drops the orphans. +export const derivePositionalFrame = ( + result: CellResult | null | undefined, +): StatementFrame | null => { + if (!result || result.results.length === 0) return null + const keys = statementKeysFor(result.results.map((r) => r.query)) + return { + slots: result.results.map((r, index) => ({ + key: keys[index], + sql: r.query, + result: r, + })), + activeSlotIndex: clampIndex( + result.activeResultIndex, + result.results.length, + ), + } +} + export const cloneNotebookViewStateWithCellIdMap = ( source: NotebookViewState, newId: () => string = generateId, @@ -951,26 +1150,34 @@ export const buildAppliedCells = ( if (existing) { updated.push(existing.id) const valueChanged = existing.value !== value + // Results carry over by statement content: unchanged statements keep + // theirs, zero survivors collapse the frame. A released cell (result on + // disk only) keeps its snapshot — hydration reconciles it on load. const next: NotebookCell = { ...existing, id: existing.id, position: index, value, - result: valueChanged ? null : existing.result, + result: valueChanged + ? reconcileCellResultForValue(existing.result, value) + : existing.result, } - if (valueChanged) { - // Preserve run history so agents still see a committed write survived a - // value edit — but an error belonged to the SQL just replaced, so - // carrying it forward would resurrect a stale error on the fixed cell. + const resultDropped = + valueChanged && existing.result != null && next.result === null + if (resultDropped) { + // The whole frame is gone — collapse the run outcome into recorded + // history the way a release would. The record describes the last run + // that actually happened; only a run rewrites it. const carried = carriedRunStatus(existing) - delete next.lastRunError - if (carried === "error") delete next.lastRunStatus - else if (carried !== undefined) next.lastRunStatus = carried + const carriedError = carriedRunError(existing) + if (carried !== undefined) next.lastRunStatus = carried + if (carriedError !== undefined) next.lastRunError = carriedError + else delete next.lastRunError } const hadRunResult = existing.result != null || (existing.lastRunStatus != null && existing.lastRunStatus !== "none") - if (hadRunResult && (valueChanged || resolvedType === "markdown")) { + if ((resolvedType === "markdown" && hadRunResult) || resultDropped) { resultsCleared.push(existing.id) } if (resolvedMode !== undefined) next.mode = resolvedMode @@ -1269,7 +1476,7 @@ export const patchCellRunResult = ( ): NotebookCell[] => cells.map((cell) => { if (cell.id !== cellId) return cell - const next: NotebookCell = { ...cell, result } + const next: NotebookCell = { ...cell, result, ...runHistoryPatch(result) } if ( !cell.bottomResized && cell.mode !== "draw" && @@ -1594,8 +1801,16 @@ export const buildAppliedNotebookState = ( nextMaximizedCellId = null } + // Apply is a PUT: an omitted auto_refresh cleared any prior override, so + // draw cells are re-stamped against the post-apply default — charts stay + // born-live no matter who composed the state. + const stampedCells = cells.map((cell) => { + const stamp = chartAutoRefreshStamp(cell, nextSettings.autoRefreshDefault) + return "autoRefresh" in stamp ? { ...cell, ...stamp } : cell + }) + return { - cells, + cells: stampedCells, settings: nextSettings, maximizedCellId: nextMaximizedCellId, diff, diff --git a/src/scenes/Editor/Notebook/result-table/InlineResultTable.tsx b/src/scenes/Editor/Notebook/result-table/InlineResultTable.tsx index c29463e4a..8b2bad456 100644 --- a/src/scenes/Editor/Notebook/result-table/InlineResultTable.tsx +++ b/src/scenes/Editor/Notebook/result-table/InlineResultTable.tsx @@ -1,26 +1,30 @@ import React from "react" -import type { CellResult } from "../../../../store/notebook" import { ResultGridPanel } from "./ResultGridPanel" import { StatusNotification } from "./StatusNotification" import { TabBar } from "./TabBar" import { ResultWrapper, SuccessMessage } from "./styles" +import type { StatementSlotView } from "./statementSlotView" import type { ResultGridViewportStore } from "./resultGridViewportStore" type Props = { - result: CellResult + slots: StatementSlotView[] + activeSlotIndex: number + timestamp: number isFocused: boolean - onTabChange: (index: number) => void - onCancelQuery: (index: number) => void + onTabChange: (statementKey: string) => void + onCancelQuery: (statementKey: string) => void bufferId: number cellId: string isRunning: boolean - onReRun: (index: number) => void + onReRun: (statementKey: string) => void onYieldFocus: () => void viewportStore: ResultGridViewportStore } export const InlineResultTable: React.FC = ({ - result, + slots, + activeSlotIndex, + timestamp, isFocused, onTabChange, onCancelQuery, @@ -31,7 +35,7 @@ export const InlineResultTable: React.FC = ({ onYieldFocus, viewportStore, }) => { - if (result.results.length === 0) { + if (slots.length === 0) { return ( OK @@ -39,33 +43,37 @@ export const InlineResultTable: React.FC = ({ ) } - const activeResult = - result.results[result.activeResultIndex] ?? result.results[0] - const isMultiQuery = result.results.length > 1 + const activeSlot = slots[activeSlotIndex] ?? slots[0] + const activeResult = activeSlot.result + const isMultiQuery = slots.length > 1 return ( - {isMultiQuery && } - - {activeResult && ( - )} + + {activeResult?.type === "dql" && activeResult.columns.length > 0 && ( onReRun(result.activeResultIndex)} + onReRun={() => onReRun(activeSlot.key)} onYieldFocus={onYieldFocus} viewportStore={viewportStore} /> diff --git a/src/scenes/Editor/Notebook/result-table/ResultGridPanel.tsx b/src/scenes/Editor/Notebook/result-table/ResultGridPanel.tsx index a720b1809..a22807659 100644 --- a/src/scenes/Editor/Notebook/result-table/ResultGridPanel.tsx +++ b/src/scenes/Editor/Notebook/result-table/ResultGridPanel.tsx @@ -20,6 +20,9 @@ import { useLocalStorage } from "../../../../providers/LocalStorageProvider" type Props = { data: DqlQueryResult + // Statement identity for the viewport store. The column layout stays keyed + // by query text alone — duplicate statements share identical columns. + viewportKey: string runToken: number isFocused: boolean bufferId: number @@ -34,23 +37,25 @@ const useInitialGridState = ({ bufferId, cellId, data, + viewportKey, runToken, viewportStore, }: Pick< Props, - "bufferId" | "cellId" | "data" | "runToken" | "viewportStore" + "bufferId" | "cellId" | "data" | "viewportKey" | "runToken" | "viewportStore" >) => useMemo(() => { const queryKey = columnLayoutQueryKey(data.query) return { queryKey, columnLayout: loadNotebookColumnLayout(bufferId, cellId, queryKey), - viewport: viewportStore.load(queryKey, runToken), + viewport: viewportStore.load(viewportKey, runToken), } - }, [bufferId, cellId, data.query, runToken, viewportStore]) + }, [bufferId, cellId, data.query, viewportKey, runToken, viewportStore]) export const ResultGridPanel: React.FC = ({ data, + viewportKey, runToken, isFocused, bufferId, @@ -64,6 +69,7 @@ export const ResultGridPanel: React.FC = ({ bufferId, cellId, data, + viewportKey, runToken, viewportStore, }) @@ -79,8 +85,8 @@ export const ResultGridPanel: React.FC = ({ ) const saveViewport = useCallback( (nextViewport: ResultGridViewport) => - viewportStore.save(queryKey, runToken, nextViewport), - [viewportStore, queryKey, runToken], + viewportStore.save(viewportKey, runToken, nextViewport), + [viewportStore, viewportKey, runToken], ) return ( diff --git a/src/scenes/Editor/Notebook/result-table/StatusNotification.tsx b/src/scenes/Editor/Notebook/result-table/StatusNotification.tsx index 2eb44f56a..8017f4340 100644 --- a/src/scenes/Editor/Notebook/result-table/StatusNotification.tsx +++ b/src/scenes/Editor/Notebook/result-table/StatusNotification.tsx @@ -1,17 +1,25 @@ import React from "react" import { Stop } from "@styled-icons/remix-line" -import { Queue } from "@phosphor-icons/react" +import { ArrowClockwiseIcon, Queue } from "@phosphor-icons/react" import { Box, Text } from "../../../../components" import Notification from "../../../Notifications/Notification" import { NotificationType } from "../../../../store/Query/types" -import type { CellResult, SingleQueryResult } from "../../../../store/notebook" +import type { SingleQueryResult } from "../../../../store/notebook" import QueryResult from "../../QueryResult" import { QueryInNotification } from "../../Monaco/query-in-notification" +import type { StatementSlotView } from "./statementSlotView" import { CancelButton, LiveRegion, NotificationContainer } from "./styles" import { trackEvent } from "../../../../modules/ConsoleEventTracker" import { ConsoleEvent } from "../../../../modules/ConsoleEventTracker/events" -const liveRegionMessage = (result: SingleQueryResult): string => { +const liveRegionMessage = (slot: StatementSlotView): string => { + if (slot.refreshing) return "Refreshing query" + if (slot.result?.type === "running") return "Query running" + if (slot.refreshError !== undefined) { + return `Refresh failed: ${slot.refreshError}` + } + if (slot.result === null) return "Query not run" + const { result } = slot switch (result.type) { case "running": return "Query running" @@ -34,54 +42,96 @@ const liveRegionMessage = (result: SingleQueryResult): string => { type Props = { timestamp: number - activeResult: SingleQueryResult - activeIndex: CellResult["activeResultIndex"] - onCancelQuery?: (index: number) => void + slot: StatementSlotView + onCancelQuery?: (statementKey: string) => void } export const StatusNotification: React.FC = ({ timestamp, - activeResult, - activeIndex, + slot, onCancelQuery, }) => { + const activeResult: SingleQueryResult = slot.result ?? { + type: "queued", + query: slot.sql, + } const { type } = activeResult - const isError = type === "error" + const isError = + type === "error" || (slot.refreshError !== undefined && type !== "running") const isCancelled = type === "cancelled" const notice = activeResult.type === "dql" ? activeResult.notice : undefined const baseProps = { query: "@0-0" as const, - createdAt: new Date(timestamp), + createdAt: new Date(slot.fetchedAt ?? timestamp), compact: true, isMinimized: true, - sideContent: , + sideContent: , } + const cancelButton = onCancelQuery && ( + { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_RUN_CANCEL) + onCancelQuery(slot.key) + }} + > + + + ) + let body: React.ReactElement - if (type === "running") { + // A live run wins over refresh state; refresh state wins over the settled + // result the tab still shows — those rows are the previous round's, and + // the line must say so. + if (slot.refreshing) { + body = ( + + Refreshing... + {cancelButton} + + } + type={NotificationType.LOADING} + /> + ) + } else if (type === "running") { body = ( Running... - {onCancelQuery && ( - { - void trackEvent(ConsoleEvent.NOTEBOOK_CELL_RUN_CANCEL) - onCancelQuery(activeIndex) - }} - > - - - )} + {cancelButton} } type={NotificationType.LOADING} /> ) + } else if (slot.refreshError !== undefined) { + body = ( + + + {`Refresh failed: ${slot.refreshError}`} + + } + type={NotificationType.ERROR} + /> + ) + } else if (slot.result === null) { + body = ( + Not run} + type={NotificationType.INFO} + /> + ) } else if (type === "queued") { body = ( = ({ type={NotificationType.ERROR} /> ) - } else if (isError) { + } else if (activeResult.type === "error") { body = ( = ({ aria-atomic="true" title={notice} > - {liveRegionMessage(activeResult)} + {liveRegionMessage(slot)} {body} ) diff --git a/src/scenes/Editor/Notebook/result-table/TabBar.tsx b/src/scenes/Editor/Notebook/result-table/TabBar.tsx index 10a438f26..c1c760ca4 100644 --- a/src/scenes/Editor/Notebook/result-table/TabBar.tsx +++ b/src/scenes/Editor/Notebook/result-table/TabBar.tsx @@ -1,10 +1,10 @@ import React from "react" import { CheckmarkOutline, CloseOutline } from "@styled-icons/evaicons-outline" -import { Queue } from "@phosphor-icons/react" -import type { CellResult, SingleQueryResult } from "../../../../store/notebook" +import { ArrowClockwiseIcon, MinusIcon, Queue } from "@phosphor-icons/react" import { trackEvent } from "../../../../modules/ConsoleEventTracker" import { ConsoleEvent } from "../../../../modules/ConsoleEventTracker/events" import { LoadingIconSvg } from "../../Monaco/icons" +import type { StatementSlotView } from "./statementSlotView" import { CancelledIcon, Tab, @@ -21,16 +21,32 @@ const truncateQuery = (query: string, maxLen = 30): string => { : oneLine } -const StatusIcon: React.FC<{ type: SingleQueryResult["type"] }> = ({ - type, -}) => { - if (type === "running") { +// A refresh keeps the old rows on screen, so the tab — not the grid — carries +// the refresh state: a spinner while in flight, a red refresh icon when the +// last round failed. A statement with no result yet is neutral, never an error. +const SlotIcon: React.FC<{ slot: StatementSlotView }> = ({ slot }) => { + if (slot.refreshing || slot.result?.type === "running") { return ( ) } + if (slot.refreshError !== undefined) { + return ( + + + + ) + } + if (slot.result === null) { + return ( + + + + ) + } + const { type } = slot.result if (type === "queued") { return ( @@ -57,35 +73,40 @@ const StatusIcon: React.FC<{ type: SingleQueryResult["type"] }> = ({ } type Props = { - result: CellResult - onTabChange?: (index: number) => void + slots: StatementSlotView[] + activeSlotIndex: number + onTabChange?: (statementKey: string) => void } -export const TabBar: React.FC = ({ result, onTabChange }) => ( +export const TabBar: React.FC = ({ + slots, + activeSlotIndex, + onTabChange, +}) => ( - {result.results.map((r, i) => ( + {slots.map((slot, i) => ( { - if (i !== result.activeResultIndex) { + if (i !== activeSlotIndex) { void trackEvent(ConsoleEvent.NOTEBOOK_RESULT_TAB_SWITCH, { tabIndex: i, - tabCount: result.results.length, - resultType: r.type, + tabCount: slots.length, + resultType: slot.result?.type ?? "none", }) } - onTabChange?.(i) + onTabChange?.(slot.key) }} - title={r.query} + title={slot.sql} role="tab" - aria-selected={i === result.activeResultIndex} + aria-selected={i === activeSlotIndex} > - - {truncateQuery(r.query)} + + {truncateQuery(slot.sql)} ))} diff --git a/src/scenes/Editor/Notebook/result-table/resultGridViewportStore.test.ts b/src/scenes/Editor/Notebook/result-table/resultGridViewportStore.test.ts index 886b29bf5..795319a5b 100644 --- a/src/scenes/Editor/Notebook/result-table/resultGridViewportStore.test.ts +++ b/src/scenes/Editor/Notebook/result-table/resultGridViewportStore.test.ts @@ -2,39 +2,46 @@ import { describe, expect, it } from "vitest" import { createResultGridViewportStore } from "./resultGridViewportStore" describe("resultGridViewportStore", () => { - it("restores offsets for the same query result", () => { - // Given a result with a saved viewport + it("restores offsets for the same statement and settle token", () => { + // Given a statement with a saved viewport const store = createResultGridViewportStore() - store.replaceResult(100) store.save("q1", 100, { scrollTop: 640, scrollLeft: 320 }) - // When the same result is mounted again + // When the same statement is mounted again at the same token const viewport = store.load("q1", 100) // Then both offsets are restored expect(viewport).toEqual({ scrollTop: 640, scrollLeft: 320 }) }) - it("drops old offsets and rejects their late unmount save after a rerun", () => { - // Given two query viewports from the current result + it("drops only the statement that got new rows — siblings keep their scroll", () => { + // Given two statements' viewports from the same round const store = createResultGridViewportStore() - store.replaceResult(100) store.save("q1", 100, { scrollTop: 640, scrollLeft: 320 }) store.save("q2", 100, { scrollTop: 480, scrollLeft: 240 }) - // When a new result arrives before the previous grid cleanup finishes - store.replaceResult(101) - store.save("q1", 100, { scrollTop: 800, scrollLeft: 400 }) + // When only the first statement settles again with fresh rows + store.save("q1", 101, { scrollTop: 800, scrollLeft: 400 }) - // Then no viewport from the previous result survives + // Then its old offsets are gone while the untouched sibling survives expect(store.load("q1", 100)).toBeNull() - expect(store.load("q2", 100)).toBeNull() + expect(store.load("q1", 101)).toEqual({ scrollTop: 800, scrollLeft: 400 }) + expect(store.load("q2", 100)).toEqual({ scrollTop: 480, scrollLeft: 240 }) }) - it("retains at most twenty query viewports for a mounted cell", () => { + it("rejects a load whose settle token no longer matches", () => { + // Given a viewport saved under an earlier settle + const store = createResultGridViewportStore() + store.save("q1", 100, { scrollTop: 640, scrollLeft: 320 }) + + // When the statement re-renders after a refresh swapped its rows + // Then the stale scroll is not restored + expect(store.load("q1", 101)).toBeNull() + }) + + it("retains at most twenty statement viewports for a mounted cell", () => { // Given a mounted cell that has visited twenty-one result tabs const store = createResultGridViewportStore() - store.replaceResult(100) for (let i = 0; i <= 20; i++) { store.save(`q${i}`, 100, { scrollTop: i, scrollLeft: i }) } @@ -51,7 +58,6 @@ describe("resultGridViewportStore", () => { it("releases all offsets when the owning cell unmounts", () => { // Given a mounted cell with a saved viewport const store = createResultGridViewportStore() - store.replaceResult(100) store.save("q1", 100, { scrollTop: 640, scrollLeft: 320 }) // When its owner clears the store during unmount diff --git a/src/scenes/Editor/Notebook/result-table/resultGridViewportStore.ts b/src/scenes/Editor/Notebook/result-table/resultGridViewportStore.ts index b714d1a85..a2f297bce 100644 --- a/src/scenes/Editor/Notebook/result-table/resultGridViewportStore.ts +++ b/src/scenes/Editor/Notebook/result-table/resultGridViewportStore.ts @@ -4,11 +4,14 @@ const MAX_VIEWPORTS_PER_CELL = 20 type ViewportEntry = ResultGridViewport & { runToken: number } +// Keyed per STATEMENT (normalized text + occurrence), not per frame: a +// refresh settles one slot at a time, so only the statement that actually got +// new rows loses its saved scroll. The entry's own token carries that — a +// stale token simply fails to load. export type ResultGridViewportStore = { - replaceResult: (runToken: number) => void - load: (queryKey: string, runToken: number) => ResultGridViewport | null + load: (statementKey: string, runToken: number) => ResultGridViewport | null save: ( - queryKey: string, + statementKey: string, runToken: number, viewport: ResultGridViewport, ) => void @@ -20,28 +23,17 @@ const normalizeOffset = (value: number): number => export const createResultGridViewportStore = (): ResultGridViewportStore => { const entries = new Map() - let currentResultToken: number | undefined return { - replaceResult(runToken) { - if (currentResultToken !== undefined && currentResultToken !== runToken) { - entries.clear() - } - currentResultToken = runToken - }, - - load(queryKey, runToken) { - const entry = entries.get(queryKey) + load(statementKey, runToken) { + const entry = entries.get(statementKey) if (!entry || entry.runToken !== runToken) return null return { scrollTop: entry.scrollTop, scrollLeft: entry.scrollLeft } }, - save(queryKey, runToken, viewport) { - if (currentResultToken !== undefined && currentResultToken !== runToken) { - return - } - entries.delete(queryKey) - entries.set(queryKey, { + save(statementKey, runToken, viewport) { + entries.delete(statementKey) + entries.set(statementKey, { runToken, scrollTop: normalizeOffset(viewport.scrollTop), scrollLeft: normalizeOffset(viewport.scrollLeft), @@ -55,7 +47,6 @@ export const createResultGridViewportStore = (): ResultGridViewportStore => { clear() { entries.clear() - currentResultToken = undefined }, } } diff --git a/src/scenes/Editor/Notebook/result-table/statementSlotView.test.ts b/src/scenes/Editor/Notebook/result-table/statementSlotView.test.ts new file mode 100644 index 000000000..7bceb2b65 --- /dev/null +++ b/src/scenes/Editor/Notebook/result-table/statementSlotView.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest" +import { buildStatementSlotViews } from "./statementSlotView" +import { deriveStatementFrame, statementKeysFor } from "../notebookUtils" +import type { CellFetchState } from "../cellRefresh/cellRefreshEngine" +import type { CellResult, SingleQueryResult } from "../../../../store/notebook" + +const dql = (query: string): SingleQueryResult => ({ + type: "dql", + query, + columns: [{ name: "x", type: "INT" }], + dataset: [[1]], + count: 1, +}) + +const result = (queries: string[]): CellResult => ({ + results: queries.map(dql), + activeResultIndex: 0, + timestamp: 0, +}) + +const fetchState = (over: Partial = {}): CellFetchState => ({ + queries: [], + queriesKey: "", + fetching: false, + settledKey: null, + classifyBlock: null, + classifiedKey: null, + slotFetching: new Set(), + slotErrors: new Map(), + cancelledSlots: new Set(), + slotFetchedAt: new Map(), + slotSwappedAt: new Map(), + ...over, +}) + +describe("buildStatementSlotViews", () => { + it("attaches refresh state to slots by statement content", () => { + // Given a two-statement frame where the second is refreshing and the + // first failed its last round + const statements = ["select 1", "select 2"] + const [key1, key2] = statementKeysFor(statements) + const frame = deriveStatementFrame(statements, result(statements))! + const state = fetchState({ + slotFetching: new Set([key2]), + slotErrors: new Map([[key1, "boom"]]), + slotFetchedAt: new Map([[key1, 1234]]), + slotSwappedAt: new Map([[key1, 1200]]), + }) + + // When the slot views are built + const slots = buildStatementSlotViews(frame, state) + + // Then each slot carries its own refresh state, freshness, and swap token + expect(slots[0]).toMatchObject({ + key: key1, + refreshing: false, + refreshError: "boom", + fetchedAt: 1234, + swappedAt: 1200, + }) + expect(slots[1]).toMatchObject({ key: key2, refreshing: true }) + expect(slots[1].refreshError).toBeUndefined() + expect(slots[1].swappedAt).toBeUndefined() + }) + + it("marks a statement with no result as not run, with no refresh state", () => { + // Given a frame whose second statement was added since the last run + const statements = ["select 1", "select 2"] + const frame = deriveStatementFrame(statements, result(["select 1"]))! + + // When the slot views are built with no engine state at all + const slots = buildStatementSlotViews(frame, undefined) + + // Then the added statement is a neutral, idle slot + expect(slots).toHaveLength(2) + expect(slots[1].result).toBeNull() + expect(slots[1].refreshing).toBe(false) + expect(slots[1].refreshError).toBeUndefined() + }) + + it("keeps duplicate statements' refresh state separate by occurrence", () => { + // Given two identical statements, only the second refreshing + const statements = ["select 1", "select 1"] + const [first, second] = statementKeysFor(statements) + const frame = deriveStatementFrame(statements, result(statements))! + const slots = buildStatementSlotViews( + frame, + fetchState({ slotFetching: new Set([second]) }), + ) + + // Then only that occurrence shows the spinner + expect(slots[0]).toMatchObject({ key: first, refreshing: false }) + expect(slots[1]).toMatchObject({ key: second, refreshing: true }) + }) +}) diff --git a/src/scenes/Editor/Notebook/result-table/statementSlotView.ts b/src/scenes/Editor/Notebook/result-table/statementSlotView.ts new file mode 100644 index 000000000..0b274b295 --- /dev/null +++ b/src/scenes/Editor/Notebook/result-table/statementSlotView.ts @@ -0,0 +1,37 @@ +import type { SingleQueryResult } from "../../../../store/notebook" +import type { CellFetchState } from "../cellRefresh/cellRefreshEngine" +import type { StatementFrame } from "../notebookUtils" + +// One tab's view model. Tabs follow the editor's statement list, not the +// compact result array: a statement with no result yet renders the neutral +// "Not run" state, and refresh state attaches by content, never by index. +export type StatementSlotView = { + key: string + sql: string + result: SingleQueryResult | null + refreshing: boolean + refreshError?: string + // Last successful poll — the status line's time. + fetchedAt?: number + // Last poll that changed the rows — the grid's viewport/focus reset token. + swappedAt?: number +} + +export const buildStatementSlotViews = ( + frame: StatementFrame, + fetchState: CellFetchState | undefined, +): StatementSlotView[] => + frame.slots.map((slot) => { + const refreshError = fetchState?.slotErrors.get(slot.key) + const fetchedAt = fetchState?.slotFetchedAt.get(slot.key) + const swappedAt = fetchState?.slotSwappedAt.get(slot.key) + return { + key: slot.key, + sql: slot.sql, + result: slot.result, + refreshing: fetchState?.slotFetching.has(slot.key) ?? false, + ...(refreshError !== undefined ? { refreshError } : {}), + ...(fetchedAt !== undefined ? { fetchedAt } : {}), + ...(swappedAt !== undefined ? { swappedAt } : {}), + } + }) diff --git a/src/scenes/Editor/Notebook/resultHydration/cellResultHydration.test.ts b/src/scenes/Editor/Notebook/resultHydration/cellResultHydration.test.ts index 93876d1d7..08ec2180a 100644 --- a/src/scenes/Editor/Notebook/resultHydration/cellResultHydration.test.ts +++ b/src/scenes/Editor/Notebook/resultHydration/cellResultHydration.test.ts @@ -6,6 +6,7 @@ import type { } from "../../../../store/notebook" import type { NotebookResultSnapshot } from "../../../../store/notebookResults" import { CellVirtualizationEngine } from "../cellVirtualization/cellVirtualizationEngine" +import { statementKeysFor } from "../notebookUtils" import { CellResultHydrationEngine } from "./cellResultHydration" vi.mock("../notebookScheduling", () => ({ @@ -47,6 +48,12 @@ describe("CellResultHydrationEngine", () => { let applied: Array<[string, CellResult]> let released: string[] let releasableCellIds: Set + let rewrites: NotebookResultSnapshot[] + let pendingRewrites: Array<(saved: boolean) => void> + let deletedSnapshots: string[] + let seededErrors: Array< + [string, Array<{ statementKey: string; message: string }>] + > const ranCell = (id: string): NotebookCell => ({ id, @@ -78,6 +85,10 @@ describe("CellResultHydrationEngine", () => { applied = [] released = [] releasableCellIds = new Set() + rewrites = [] + pendingRewrites = [] + deletedSnapshots = [] + seededErrors = [] engine = new CellResultHydrationEngine({ loadSnapshot: (cellId) => new Promise((resolve, reject) => { @@ -89,6 +100,20 @@ describe("CellResultHydrationEngine", () => { }) pendingLoads.set(cellId, handles) }), + rewriteSnapshot: (rewritten) => + new Promise((resolve) => { + rewrites.push(rewritten) + snapshots.set(rewritten.cellId, rewritten) + pendingRewrites.push(resolve) + }), + deleteSnapshot: (cellId) => { + deletedSnapshots.push(cellId) + snapshots.delete(cellId) + return Promise.resolve() + }, + seedRefreshErrors: (cellId, errors) => { + seededErrors.push([cellId, errors]) + }, getCell: (cellId) => cells.get(cellId), applyResult: (cellId, result) => { applied.push([cellId, result]) @@ -133,6 +158,7 @@ describe("CellResultHydrationEngine", () => { { results: [dqlResult("select 1")], activeResultIndex: 0, + activeStatementKey: statementKeysFor(["select 1"])[0], timestamp: 1000, }, ], @@ -141,7 +167,7 @@ describe("CellResultHydrationEngine", () => { it("restores the viewed tab and script summary from the snapshot", async () => { // Given a script cell's snapshot saved on its second tab with a summary - seedCell(ranCell("c1")) + seedCell({ ...ranCell("c1"), value: "select 1; select 2" }) const results = [dqlResult("select 1"), dqlResult("select 2")] const script = { successCount: 2, failedCount: 0, durationMs: 12 } snapshots.set( @@ -155,13 +181,22 @@ describe("CellResultHydrationEngine", () => { // Then the user lands on the tab they were viewing, summary intact expect(applied).toEqual([ - ["c1", { results, activeResultIndex: 1, timestamp: 1000, script }], + [ + "c1", + { + results, + activeResultIndex: 1, + activeStatementKey: statementKeysFor(["select 1", "select 2"])[1], + timestamp: 1000, + script, + }, + ], ]) }) it("re-hydrates a released cell with the exact same result", async () => { // Given a hydrated script cell - seedCell(ranCell("c1")) + seedCell({ ...ranCell("c1"), value: "select 1; select 2" }) const results = [dqlResult("select 1"), dqlResult("select 2")] const script = { successCount: 2, failedCount: 0, durationMs: 12 } snapshots.set( @@ -185,6 +220,115 @@ describe("CellResultHydrationEngine", () => { expect(applied[1][1]).toEqual(applied[0][1]) }) + it("reconciles a snapshot against SQL edited while unmounted — survivors hydrate, the rest drop", async () => { + // Given a snapshot saved for two statements, one of which was edited away + seedCell({ ...ranCell("c1"), value: "select 1; select 3" }) + snapshots.set( + "c1", + snapshot("c1", [dqlResult("select 1"), dqlResult("select 2")], { + activeResultIndex: 1, + script: { successCount: 2, failedCount: 0, durationMs: 12 }, + }), + ) + + // When it hydrates + engine.request("c1") + await resolveLoad("c1") + + // Then only the surviving statement's result is exposed, without the + // stale script summary, and the reconciled frame is rewritten to disk + expect(applied).toHaveLength(1) + expect(applied[0][1].results).toEqual([dqlResult("select 1")]) + expect(applied[0][1].script).toBeUndefined() + expect(rewrites).toHaveLength(1) + expect(rewrites[0].results).toEqual([dqlResult("select 1")]) + expect(rewrites[0].script).toBeUndefined() + }) + + it("keeps a reconciled frame in memory until its rewrite confirms", async () => { + // Given a hydrated cell whose snapshot needed reconciliation + seedCell({ ...ranCell("c1"), value: "select 1" }) + snapshots.set( + "c1", + snapshot("c1", [dqlResult("select 1"), dqlResult("select 2")]), + ) + engine.request("c1") + await resolveLoad("c1") + + // When it becomes releasable before the rewrite resolves + releasableCellIds.add("c1") + engine.noteReleasable("c1") + await vi.advanceTimersByTimeAsync(1) + + // Then the unconfirmed frame stays in memory + expect(released).toEqual([]) + + // When the rewrite confirms + pendingRewrites.shift()?.(true) + await vi.advanceTimersByTimeAsync(0) + engine.noteReleasable("c1") + await vi.advanceTimersByTimeAsync(1) + + // Then the release lands + expect(released).toEqual(["c1"]) + }) + + it("restores a legacy record whose query holds the raw cell text, comments included", async () => { + // Given a record written before recording was fixed: the single run + // stored the whole cell text — leading comment included — as the query + seedCell({ ...ranCell("c1"), value: "-- note\nselect 1" }) + snapshots.set("c1", snapshot("c1", [dqlResult("-- note\nselect 1")])) + + // When it hydrates + engine.request("c1") + await resolveLoad("c1") + + // Then the result survives under the parsed statement instead of being + // deleted as an orphan, and the converged record is rewritten to disk + expect(deletedSnapshots).toEqual([]) + expect(applied).toHaveLength(1) + expect(applied[0][1].results[0]).toMatchObject({ query: "select 1" }) + expect(rewrites).toHaveLength(1) + expect(rewrites[0].results[0]).toMatchObject({ query: "select 1" }) + }) + + it("deletes the snapshot and resolves missing when no statement survives", async () => { + // Given a snapshot whose only statement was rewritten + seedCell({ ...ranCell("c1"), value: "select 99" }) + snapshots.set("c1", snapshot("c1", [dqlResult("select 1")])) + + // When it hydrates + engine.request("c1") + await resolveLoad("c1") + + // Then nothing is applied, the cell collapses, and the snapshot is gone + expect(applied).toEqual([]) + expect(engine.statusOf("c1")).toBe("missing") + expect(deletedSnapshots).toEqual(["c1"]) + }) + + it("keeps only surviving statements' refresh errors in the rewritten snapshot", async () => { + // Given persisted refresh errors for a surviving and a removed statement + seedCell({ ...ranCell("c1"), value: "select 1" }) + snapshots.set("c1", { + ...snapshot("c1", [dqlResult("select 1"), dqlResult("select 2")]), + refreshErrors: [ + { statementKey: statementKeysFor(["select 1"])[0], message: "boom" }, + { statementKey: statementKeysFor(["select 2"])[0], message: "gone" }, + ], + }) + + // When it hydrates + engine.request("c1") + await resolveLoad("c1") + + // Then the removed statement's error is dropped from the rewrite + expect(rewrites).toHaveLength(1) + expect(rewrites[0].refreshErrors).toEqual([ + { statementKey: statementKeysFor(["select 1"])[0], message: "boom" }, + ]) + }) + it("never clobbers a live result that lands while the snapshot load is in flight", async () => { // Given a requested cell whose read is still pending seedCell(ranCell("c1")) @@ -592,6 +736,77 @@ describe("CellResultHydrationEngine", () => { engine.forget("c2") expect(anyListener).toHaveBeenCalledTimes(2) }) + + describe("reviveMissing", () => { + it("restores the retained snapshot when the cell's SQL matches again", async () => { + // Given a cell whose display collapsed but whose snapshot survived + seedCell(ranCell("c1")) + snapshots.set("c1", snapshot("c1", [dqlResult("select 1")])) + engine.noteMissing("c1") + + // When the SQL settles back to matching and the revive load resolves + engine.reviveMissing("c1") + await resolveLoad("c1") + + // Then the rows come back and the snapshot is untouched + expect(applied).toHaveLength(1) + expect(applied[0][1].results[0]).toMatchObject({ query: "select 1" }) + expect(engine.statusOf("c1")).toBe("loaded") + expect(deletedSnapshots).toEqual([]) + }) + + it("keeps the snapshot when the revive finds zero survivors", async () => { + // Given a collapsed cell whose current SQL matches nothing on disk + seedCell({ ...ranCell("c1"), value: "select 999" }) + snapshots.set("c1", snapshot("c1", [dqlResult("select 1")])) + engine.noteMissing("c1") + + // When the revive load resolves against the non-matching text + engine.reviveMissing("c1") + await resolveLoad("c1") + + // Then nothing applies, the snapshot survives for a later matching + // settle, and the status returns to missing + expect(applied).toEqual([]) + expect(deletedSnapshots).toEqual([]) + expect(snapshots.has("c1")).toBe(true) + expect(engine.statusOf("c1")).toBe("missing") + + // And a later matching settle still gets its rows back + seedCell({ ...ranCell("c1"), value: "select 1" }) + engine.reviveMissing("c1") + await resolveLoad("c1") + expect(applied).toHaveLength(1) + }) + + it("a normal load's zero-survivor reconcile still deletes the snapshot", async () => { + // Given a cell whose stored rows match nothing in its current SQL + seedCell({ ...ranCell("c1"), value: "select 999" }) + snapshots.set("c1", snapshot("c1", [dqlResult("select 1")])) + + // When a plain band-entry load resolves + engine.request("c1") + await resolveLoad("c1") + + // Then the terminal delete still applies — revive semantics never leak + // into normal loads + expect(deletedSnapshots).toEqual(["c1"]) + expect(engine.statusOf("c1")).toBe("missing") + }) + + it("does nothing unless the cell is known missing", () => { + // Given a cell that was never requested + seedCell(ranCell("c1")) + snapshots.set("c1", snapshot("c1", [dqlResult("select 1")])) + + // When a stray revive arrives + engine.reviveMissing("c1") + + // Then no load starts + expect(loadCounts.get("c1")).toBeUndefined() + expect(engine.statusOf("c1")).toBe("unrequested") + }) + }) }) // Mirrors the NotebookProvider wiring: band callbacks drive request / @@ -639,6 +854,9 @@ describe("virtualization band → hydration engine wiring", () => { }) pendingLoads.set(cellId, handles) }), + rewriteSnapshot: () => Promise.resolve(true), + deleteSnapshot: () => Promise.resolve(), + seedRefreshErrors: () => undefined, getCell: (cellId) => cells.get(cellId), applyResult: (cellId, result) => { applied.push([cellId, result]) diff --git a/src/scenes/Editor/Notebook/resultHydration/cellResultHydration.ts b/src/scenes/Editor/Notebook/resultHydration/cellResultHydration.ts index a05f835f7..3eeccf763 100644 --- a/src/scenes/Editor/Notebook/resultHydration/cellResultHydration.ts +++ b/src/scenes/Editor/Notebook/resultHydration/cellResultHydration.ts @@ -5,6 +5,25 @@ import type { } from "../../../../store/notebook" import type { NotebookResultSnapshot } from "../../../../store/notebookResults" import { shallowArrayEquals } from "../../../../utils/shallowArrayEquals" +import { getQueriesFromText, normalizeQueryText } from "../../Monaco/utils" +import { + reconcileResultsForStatements, + statementKeysFor, +} from "../notebookUtils" + +// Legacy records hold the raw cell text — comments included — as the +// statement's query. Parsing it back to the statement lets those results +// survive key matching; the changed frame then rewrites to disk. +const normalizeSnapshotResultQuery = ( + result: SingleQueryResult, +): SingleQueryResult => { + const parsed = getQueriesFromText(result.query) + if (parsed.length !== 1) return result + if (normalizeQueryText(parsed[0]) === normalizeQueryText(result.query)) { + return result + } + return { ...result, query: parsed[0] } +} import { scheduleIdle } from "../notebookScheduling" import { PerKeyListeners } from "../perKeyListeners" @@ -20,10 +39,16 @@ const MAX_LOAD_RETRIES = 2 export type CellResultHydrationDeps = { loadSnapshot: (cellId: string) => Promise + rewriteSnapshot: (snapshot: NotebookResultSnapshot) => Promise + deleteSnapshot: (cellId: string) => Promise getCell: (cellId: string) => NotebookCell | undefined applyResult: (cellId: string, result: CellResult) => void releaseResult: (cellId: string) => void canRelease: (cellId: string) => boolean + seedRefreshErrors: ( + cellId: string, + errors: Array<{ statementKey: string; message: string }>, + ) => void } export const hasRunMarker = (cell: NotebookCell): boolean => @@ -45,11 +70,16 @@ export class CellResultHydrationEngine { // fails safe (the cell just never releases). private persisted = new WeakSet() private lastSyncedCellIds: string[] | null = null + // Cells whose current load is a revive: a zero-survivor reconcile must not + // delete the snapshot — the text that failed to match may still be + // transient, and a later matching settle (undo) needs the rows back. + private reviving = new Set() constructor(private deps: CellResultHydrationDeps) {} destroy() { this.destroyed = true + this.reviving.clear() this.statuses.clear() this.listeners.clear() this.anyListeners.clear() @@ -127,7 +157,20 @@ export class CellResultHydrationEngine { this.setStatus(cellId, "missing") } + // Re-attempts a "missing" cell's load without the delete-on-empty terminal. + // For the zero-survivor collapse: the snapshot stayed on disk, so a settle + // back to matching SQL restores the rows instead of staying blank. + reviveMissing(cellId: string) { + if (this.destroyed) return + if (this.statusOf(cellId) !== "missing") return + this.reviving.add(cellId) + this.statuses.delete(cellId) + this.request(cellId) + if (this.statusOf(cellId) !== "loading") this.reviving.delete(cellId) + } + forget(cellId: string) { + this.reviving.delete(cellId) this.releaseQueue.delete(cellId) this.loadAttempts.delete(cellId) const previous = this.statuses.get(cellId) @@ -160,27 +203,95 @@ export class CellResultHydrationEngine { return } if (cell.result != null) { + this.reviving.delete(cellId) this.setStatus(cellId, "loaded") return } if (this.deps.canRelease(cellId)) { + this.reviving.delete(cellId) this.setStatus(cellId, "unrequested") return } if (snapshot && snapshot.results.length > 0) { - this.persisted.add(snapshot.results) - this.deps.applyResult(cellId, { - results: snapshot.results, - activeResultIndex: snapshot.activeResultIndex ?? 0, - timestamp: snapshot.savedAt, - ...(snapshot.script ? { script: snapshot.script } : {}), - }) - this.setStatus(cellId, "loaded") + this.applyReconciled(cellId, cell, snapshot) return } + this.reviving.delete(cellId) this.setStatus(cellId, "missing") } + // The cell's SQL may have changed while the notebook was unmounted + // (update_cell / apply_notebook_state). Results follow statement content: + // unmatched old results are never exposed under new SQL. A changed frame is + // rewritten to disk before its array counts as persisted/releasable, and a + // frame with no survivor deletes the snapshot — every later reload agrees. + private applyReconciled( + cellId: string, + cell: NotebookCell, + snapshot: NotebookResultSnapshot, + ) { + const statements = getQueriesFromText(cell.value) + const reconciled = reconcileResultsForStatements(statements, { + results: snapshot.results.map(normalizeSnapshotResultQuery), + activeResultIndex: snapshot.activeResultIndex ?? 0, + ...(snapshot.activeStatementKey !== undefined + ? { activeStatementKey: snapshot.activeStatementKey } + : {}), + timestamp: snapshot.savedAt, + }) + if (!reconciled) { + if (!this.reviving.delete(cellId)) { + void this.deps.deleteSnapshot(cellId).catch(() => undefined) + } + this.setStatus(cellId, "missing") + return + } + this.reviving.delete(cellId) + const frameChanged = + reconciled.results.length !== snapshot.results.length || + reconciled.results.some((result, index) => { + return result !== snapshot.results[index] + }) + const slotKeys = new Set(statementKeysFor(statements)) + const refreshErrors = snapshot.refreshErrors?.filter((error) => + slotKeys.has(error.statementKey), + ) + if (frameChanged) { + const rewritten: NotebookResultSnapshot = { + ...snapshot, + results: reconciled.results, + activeResultIndex: reconciled.activeResultIndex, + activeStatementKey: reconciled.activeStatementKey, + ...(refreshErrors && refreshErrors.length > 0 ? { refreshErrors } : {}), + } + delete rewritten.script + if (!refreshErrors || refreshErrors.length === 0) { + delete rewritten.refreshErrors + } + void this.deps + .rewriteSnapshot(rewritten) + .then((saved) => { + if (saved) this.persisted.add(reconciled.results) + }) + .catch(() => undefined) + } else { + this.persisted.add(reconciled.results) + } + this.deps.applyResult(cellId, { + results: reconciled.results, + activeResultIndex: reconciled.activeResultIndex, + activeStatementKey: reconciled.activeStatementKey, + timestamp: snapshot.savedAt, + ...(snapshot.script && !frameChanged ? { script: snapshot.script } : {}), + }) + // Persisted refresh failures re-enter the engine channel, so a reload + // restores the red badge and last_refresh_error alongside the old rows. + if (refreshErrors && refreshErrors.length > 0) { + this.deps.seedRefreshErrors(cellId, refreshErrors) + } + this.setStatus(cellId, "loaded") + } + // A cell resting on screen gets no further band transitions, so a failed // read must retry itself or the shimmer never resolves. Off-screen cells // skip this: their next band entry re-requests anyway. diff --git a/src/scenes/Editor/Notebook/useCellExecution.ts b/src/scenes/Editor/Notebook/useCellExecution.ts index bd0f25882..d860d50bb 100644 --- a/src/scenes/Editor/Notebook/useCellExecution.ts +++ b/src/scenes/Editor/Notebook/useCellExecution.ts @@ -9,13 +9,24 @@ import type { QueryExecResult } from "../../../hooks/useQueryExecution" import { eventBus } from "../../../modules/EventBus" import { EventType } from "../../../modules/EventBus/types" import { getQueriesFromText } from "../Monaco/utils" +import type { ValidateQueryResult } from "../../../utils/questdb/types" +import { + hasWriteStatement, + resolveRunBarrier, + type ClassifiedStatement, + type RunBarrierOutcome, + type RunCellGate, +} from "../../../utils/tools/permissions" +import { statementRequestLimiter } from "../../../utils/questdb/requestLimiter" import { buildInitialScriptResults, type CellRunOutcome, hasPendingResult, NOTEBOOK_ROW_CAP, resolveRunCompletion, + runHistoryPatch, singleResultFromExec, + statementKeysFor, } from "./notebookUtils" import { persistCellSnapshot } from "./persistCellSnapshot" import { updateCellSnapshotActiveIndex } from "../../../store/notebookResults" @@ -69,6 +80,10 @@ type Options = { signal?: AbortSignal, limit?: number, ) => Promise + validateWithGlobals: ( + sql: string, + signal?: AbortSignal, + ) => Promise updateCellResult: ( cellId: string, index: number, @@ -88,6 +103,7 @@ export const useCellExecution = ({ bufferId, cellsRef, executeSingle, + validateWithGlobals, updateCellResult, updateCell, updateCells, @@ -98,6 +114,10 @@ export const useCellExecution = ({ const abortControllersRef = useRef>(new Map()) + // Barrier-phase claims: a run that is still validating has no per-query + // controllers yet, so cancel and delete reach it through this map. + const barrierAbortsRef = useRef>>(new Map()) + const runGenerationRef = useRef>(new Map()) const autoFocusRef = useRef>(new Map()) @@ -116,6 +136,9 @@ export const useCellExecution = ({ results: result.results, savedAt: Date.now(), activeResultIndex: result.activeResultIndex, + ...(result.activeStatementKey !== undefined + ? { activeStatementKey: result.activeStatementKey } + : {}), ...(result.script ? { script: result.script } : {}), }).then((saved) => { if (saved) onSnapshotPersisted(cellId, result.results) @@ -124,12 +147,25 @@ export const useCellExecution = ({ [bufferId, cellsRef, onSnapshotPersisted], ) + // Recorded run history: only checked run commits write it, always right + // after the commit's generation-and-SQL checks passed, from the frame the + // run just landed. + const stampRunHistory = useCallback( + (cellId: string) => { + const cell = cellsRef.current.find((c) => c.id === cellId) + if (!cell?.result) return + updateCell(cellId, runHistoryPatch(cell.result)) + }, + [cellsRef, updateCell], + ) + const runScript = useCallback( async ( cellId: string, queries: string[], externalSignal: AbortSignal | undefined, expectFullValue: boolean, + valueAtRunStart: string, ): Promise => { if (queries.length === 0) return { ok: false, superseded: false } @@ -141,7 +177,6 @@ export const useCellExecution = ({ const priorResult = hasPendingResult(startCell?.result) ? undefined : startCell?.result - const valueAtRunStart = startCell?.value // One AbortController per query so `cancelQuery(index)` cancels just that slot. const controllers = queries.map(() => new AbortController()) @@ -206,11 +241,22 @@ export const useCellExecution = ({ isAuto ? i : undefined, ) - const result = await executeSingle( - sql, - perQuery.signal, - NOTEBOOK_ROW_CAP, - ) + let result: QueryExecResult + try { + result = await statementRequestLimiter( + () => executeSingle(sql, perQuery.signal, NOTEBOOK_ROW_CAP), + perQuery.signal, + ) + } catch { + result = { + type: "error", + query: sql, + columns: [], + dataset: [], + count: 0, + error: "Cancelled by user", + } + } if (!isCurrentRun()) { return { ok: failedCount === 0, superseded: true } } @@ -277,6 +323,188 @@ export const useCellExecution = ({ failedCount, durationMs: Date.now() - startTime, }) + stampRunHistory(cellId) + persistSnapshot(cellId) + } finally { + externalSignal?.removeEventListener("abort", onExternalAbort) + if (isCurrentRun()) { + clearRunningCell( + abortControllersRef, + autoFocusRef, + setRunningCellIds, + cellId, + ) + } + } + + return { + ok: failedCount === 0, + superseded: false, + result: { + results: finalResults, + activeResultIndex: 0, + timestamp: Date.now(), + }, + } + }, + [ + cellsRef, + executeSingle, + updateCell, + updateCellResult, + setScriptSummary, + stampRunHistory, + persistSnapshot, + ], + ) + + // Parallel run for refreshable (non-write) cells: every DQL statement + // launches at once through the shared limiter; an invalid statement is + // skipped with its validation error as the slot result; one failure skips + // nothing. No tab auto-advance — completion order is random. + const runParallel = useCallback( + async ( + cellId: string, + queries: string[], + classified: ClassifiedStatement[], + externalSignal: AbortSignal | undefined, + expectFullValue: boolean, + valueAtRunStart: string, + ): Promise => { + const prior = abortControllersRef.current.get(cellId) + prior?.forEach((c) => c.abort()) + + const isCurrentRun = beginCellRun(runGenerationRef, cellId) + const startCell = cellsRef.current.find((c) => c.id === cellId) + const priorResult = hasPendingResult(startCell?.result) + ? undefined + : startCell?.result + + const controllers = queries.map(() => new AbortController()) + const onExternalAbort = () => + controllers.forEach((c) => c.abort(externalSignal?.reason)) + if (externalSignal?.aborted) { + onExternalAbort() + } else { + externalSignal?.addEventListener("abort", onExternalAbort, { + once: true, + }) + } + abortControllersRef.current.set(cellId, controllers) + autoFocusRef.current.set(cellId, false) + + const startTime = Date.now() + updateCell(cellId, { + result: { + results: buildInitialScriptResults(queries), + activeResultIndex: 0, + timestamp: Date.now(), + }, + }) + const finalResults = buildInitialScriptResults(queries) + setRunningCellIds((prev) => new Set(prev).add(cellId)) + + let failedCount = 0 + try { + await Promise.all( + queries.map(async (sql, index) => { + const stmt = classified[index] + if (stmt?.klass === "ERROR") { + failedCount++ + const invalid: SingleQueryResult = { + type: "error", + query: sql, + error: stmt.error ?? "Invalid statement", + } + finalResults[index] = invalid + if (isCurrentRun()) updateCellResult(cellId, index, invalid) + return + } + const perQuery = controllers[index] + if (perQuery.signal.aborted) { + failedCount++ + const interrupted: SingleQueryResult = { + type: "error", + query: sql, + error: "Cancelled by user", + } + finalResults[index] = interrupted + if (isCurrentRun()) updateCellResult(cellId, index, interrupted) + return + } + updateCellResult(cellId, index, { type: "running", query: sql }) + let result: QueryExecResult + try { + result = await statementRequestLimiter( + () => executeSingle(sql, perQuery.signal, NOTEBOOK_ROW_CAP), + perQuery.signal, + ) + } catch { + result = { + type: "error", + query: sql, + columns: [], + dataset: [], + count: 0, + error: "Cancelled by user", + } + } + if (!isCurrentRun()) return + const landed = singleResultFromExec(result, sql) + finalResults[index] = landed + updateCellResult(cellId, index, landed) + if (result.type === "error") failedCount++ + }), + ) + + if (!isCurrentRun()) { + return { ok: failedCount === 0, superseded: true } + } + const liveCell = cellsRef.current.find((c) => c.id === cellId) + if (!liveCell) { + return { + ok: failedCount === 0, + superseded: false, + resultCleared: true, + } + } + const completion = resolveRunCompletion( + liveCell, + valueAtRunStart, + expectFullValue, + ) + if (completion === "result_cleared") { + return { + ok: failedCount === 0, + superseded: false, + resultCleared: true, + } + } + if (completion === "cell_changed") { + updateCell(cellId, { result: priorResult }) + return { + ok: failedCount === 0, + superseded: false, + cellChanged: true, + } + } + if (!liveCell.result) { + updateCell(cellId, { + result: { + results: finalResults, + activeResultIndex: 0, + timestamp: Date.now(), + }, + }) + } + if (queries.length > 1) { + setScriptSummary(cellId, { + successCount: queries.length - failedCount, + failedCount, + durationMs: Date.now() - startTime, + }) + } + stampRunHistory(cellId) persistSnapshot(cellId) } finally { externalSignal?.removeEventListener("abort", onExternalAbort) @@ -306,6 +534,7 @@ export const useCellExecution = ({ updateCell, updateCellResult, setScriptSummary, + stampRunHistory, persistSnapshot, ], ) @@ -316,6 +545,7 @@ export const useCellExecution = ({ sql?: string, externalSignal?: AbortSignal, expectFullValue: boolean = false, + gate?: RunCellGate, ): Promise => { const notRun: CellRunOutcome = { ok: false, superseded: false } const cell = cellsRef.current.find((c) => c.id === cellId) @@ -337,15 +567,78 @@ export const useCellExecution = ({ } const queries = getQueriesFromText(queryText) + if (queries.length === 0) return notRun + + // The run claims the cell before the barrier: the attribution baseline + // is the gesture-time value, and a barrier-phase abort handle lets + // cancel and delete reach a run that is still validating. + const valueAtRunStart = cell.value + const barrierAc = new AbortController() + const onBarrierAbort = () => barrierAc.abort(externalSignal?.reason) + externalSignal?.addEventListener("abort", onBarrierAbort, { once: true }) + const claims = + barrierAbortsRef.current.get(cellId) ?? new Set() + claims.add(barrierAc) + barrierAbortsRef.current.set(cellId, claims) + + let barrier: RunBarrierOutcome + try { + barrier = await resolveRunBarrier( + queryText, + queries.length, + gate, + (stmt) => + statementRequestLimiter( + () => validateWithGlobals(stmt, barrierAc.signal), + barrierAc.signal, + ), + ) + } finally { + externalSignal?.removeEventListener("abort", onBarrierAbort) + claims.delete(barrierAc) + if (claims.size === 0) barrierAbortsRef.current.delete(cellId) + } + + // Re-check the claim after the barrier await: a cancel, delete, or + // mode change that landed during validation would otherwise launch — + // and commit — the run. + if (barrierAc.signal.aborted || externalSignal?.aborted) return notRun + if (barrier.action === "denied") { + return { ok: false, superseded: false, denied: barrier.reason } + } + if (barrier.action === "skipped") { + return { ok: false, superseded: false, skipped: barrier.reason } + } + const classified = barrier.classified + const liveAfterBarrier = cellsRef.current.find((c) => c.id === cellId) + if (!liveAfterBarrier || liveAfterBarrier.type === "markdown") { + return notRun + } + + if (classified && !hasWriteStatement(classified)) { + return runParallel( + cellId, + queries, + classified, + externalSignal, + expectFullValue, + valueAtRunStart, + ) + } if (queries.length > 1) { - return runScript(cellId, queries, externalSignal, expectFullValue) + return runScript( + cellId, + queries, + externalSignal, + expectFullValue, + valueAtRunStart, + ) } const prior = abortControllersRef.current.get(cellId) prior?.forEach((c) => c.abort()) const isCurrentRun = beginCellRun(runGenerationRef, cellId) - const valueAtRunStart = cell.value const priorRaw = cellsRef.current.find((c) => c.id === cellId)?.result const priorResult = hasPendingResult(priorRaw) ? undefined : priorRaw @@ -356,8 +649,12 @@ export const useCellExecution = ({ }) abortControllersRef.current.set(cellId, [ac]) + // Record the parsed statement, never the raw cell text: display and + // carryover attach results by statement content, and comments around the + // statement would orphan the frame. Execution still sends queryText. + const recordedQuery = queries[0] const runningResult: CellResult = { - results: [{ type: "running", query: queryText }], + results: [{ type: "running", query: recordedQuery }], activeResultIndex: 0, timestamp: Date.now(), } @@ -365,11 +662,22 @@ export const useCellExecution = ({ setRunningCellIds((prev) => new Set(prev).add(cellId)) try { - const execResult = await executeSingle( - queryText, - ac.signal, - NOTEBOOK_ROW_CAP, - ) + let execResult: QueryExecResult + try { + execResult = await statementRequestLimiter( + () => executeSingle(queryText, ac.signal, NOTEBOOK_ROW_CAP), + ac.signal, + ) + } catch { + execResult = { + type: "error", + query: queryText, + columns: [], + dataset: [], + count: 0, + error: "Cancelled by user", + } + } // A newer run (or a cancel) superseded this one; don't write its result. if (!isCurrentRun()) { return { ok: execResult.type !== "error", superseded: true } @@ -404,11 +712,14 @@ export const useCellExecution = ({ } } const cellResult: CellResult = { - results: [singleResultFromExec(execResult, queryText)], + results: [singleResultFromExec(execResult, recordedQuery)], activeResultIndex: 0, timestamp: Date.now(), } - updateCell(cellId, { result: cellResult }) + updateCell(cellId, { + result: cellResult, + ...runHistoryPatch(cellResult), + }) persistSnapshot(cellId, cellResult) return { ok: execResult.type !== "error", @@ -427,41 +738,93 @@ export const useCellExecution = ({ } } }, - [cellsRef, executeSingle, updateCell, runScript, persistSnapshot], + [ + cellsRef, + executeSingle, + validateWithGlobals, + updateCell, + runScript, + runParallel, + persistSnapshot, + ], ) + // The per-tab rerun keeps its presentation (the slot blanks to a running + // placeholder) but joins the run/refresh arbiter: it enters the run + // generation, marks the cell running so refresh ticks skip, and its commit + // is generation-checked. `committed` and `ok` separate: a committed error + // result is still newer than any stale refresh error the slot carries. const reRunResultAt = useCallback( - async (cellId: string, index: number): Promise => { + async ( + cellId: string, + index: number, + ): Promise<{ committed: boolean; ok: boolean }> => { + const notCommitted = { committed: false, ok: false } const cell = cellsRef.current.find((c) => c.id === cellId) - if (!cell?.result) return false + if (!cell?.result) return notCommitted const target = cell.result.results[index] - if (!target || !target.query.trim()) return false + if (!target || !target.query.trim()) return notCommitted const sql = target.query + const isCurrentRun = beginCellRun(runGenerationRef, cellId) const controllers = abortControllersRef.current.get(cellId) ?? [] controllers[index]?.abort() const ac = new AbortController() controllers[index] = ac abortControllersRef.current.set(cellId, controllers) + setRunningCellIds((prev) => new Set(prev).add(cellId)) updateCellResult(cellId, index, { type: "running", query: sql }) - const execResult = await executeSingle(sql, ac.signal, NOTEBOOK_ROW_CAP) - if (ac.signal.aborted) return execResult.type !== "error" - publishSchemaIfMutating(execResult) - const liveCell = cellsRef.current.find((c) => c.id === cellId) - if (!liveCell?.result) return execResult.type !== "error" - updateCellResult(cellId, index, singleResultFromExec(execResult, sql)) - persistSnapshot(cellId) - return execResult.type !== "error" + try { + let execResult: QueryExecResult + try { + execResult = await statementRequestLimiter( + () => executeSingle(sql, ac.signal, NOTEBOOK_ROW_CAP), + ac.signal, + ) + } catch { + execResult = { + type: "error", + query: sql, + columns: [], + dataset: [], + count: 0, + error: "Cancelled by user", + } + } + if (ac.signal.aborted || !isCurrentRun()) return notCommitted + publishSchemaIfMutating(execResult) + const liveCell = cellsRef.current.find((c) => c.id === cellId) + if (!liveCell?.result) return notCommitted + updateCellResult(cellId, index, singleResultFromExec(execResult, sql)) + stampRunHistory(cellId) + persistSnapshot(cellId) + return { committed: true, ok: execResult.type !== "error" } + } finally { + if (isCurrentRun()) { + setRunningCellIds((prev) => { + const next = new Set(prev) + next.delete(cellId) + return next + }) + } + } }, - [cellsRef, executeSingle, updateCellResult, persistSnapshot], + [ + cellsRef, + executeSingle, + updateCellResult, + stampRunHistory, + persistSnapshot, + ], ) // Silently discard an in-flight run: no cancelled markers, no snapshot // delete. For ownership hand-offs (run→draw) where the chart engine takes // over and the run must simply stop writing. const abortCellRun = useCallback((cellId: string) => { + barrierAbortsRef.current.get(cellId)?.forEach((ac) => ac.abort()) const controllers = abortControllersRef.current.get(cellId) if (!controllers) return // Supersede the in-flight run so its late resolution can't write back @@ -476,6 +839,7 @@ export const useCellExecution = ({ }, []) const cancelCell = useCallback((cellId: string) => { + barrierAbortsRef.current.get(cellId)?.forEach((ac) => ac.abort()) const controllers = abortControllersRef.current.get(cellId) if (!controllers) return controllers.forEach((ac) => ac.abort()) @@ -499,22 +863,42 @@ export const useCellExecution = ({ [cellsRef, updateCellResult], ) - const setActiveResultIndex = useCallback( - (cellId: string, index: number) => { + // The active tab is a STATEMENT, not a position: a tab the user selects may + // hold no result yet (added since the last run), and the compact array's + // indices shift as statements come and go. + const setActiveStatement = useCallback( + (cellId: string, statementKey: string) => { autoFocusRef.current.set(cellId, false) + const result = cellsRef.current.find((c) => c.id === cellId)?.result + if (!result) return + const resultIndex = statementKeysFor( + result.results.map((r) => r.query), + ).indexOf(statementKey) + const activeResultIndex = + resultIndex === -1 ? result.activeResultIndex : resultIndex updateCells((prev) => prev.map((c) => { if (c.id !== cellId || !c.result) return c - return { ...c, result: { ...c.result, activeResultIndex: index } } + return { + ...c, + result: { + ...c.result, + activeResultIndex, + activeStatementKey: statementKey, + }, + } }), ) // Keep the snapshot on the tab the user is viewing, so a release or a // reload restores this tab instead of snapping back to the first. - void updateCellSnapshotActiveIndex(bufferId, cellId, index).catch( - () => undefined, - ) + void updateCellSnapshotActiveIndex( + bufferId, + cellId, + activeResultIndex, + statementKey, + ).catch(() => undefined) }, - [updateCells, bufferId], + [cellsRef, updateCells, bufferId], ) useEffect(() => { @@ -538,6 +922,6 @@ export const useCellExecution = ({ abortCellRun, cancelCell, cancelQuery, - setActiveResultIndex, + setActiveStatement, } } diff --git a/src/scenes/Editor/Notebook/useNotebookPersistence.ts b/src/scenes/Editor/Notebook/useNotebookPersistence.ts index 4d888ae49..4d7a29f55 100644 --- a/src/scenes/Editor/Notebook/useNotebookPersistence.ts +++ b/src/scenes/Editor/Notebook/useNotebookPersistence.ts @@ -16,6 +16,7 @@ type Options = { maximizedCellIdRef: MutableRefObject settingsRef: MutableRefObject preview: boolean + flushRefreshSnapshots: () => void } // On unmount, flushes any pending debounced write via refs so a tab @@ -27,6 +28,7 @@ export const useNotebookPersistence = ({ maximizedCellIdRef, settingsRef, preview, + flushRefreshSnapshots, }: Options) => { const persistTimeoutRef = useRef(null) const pendingCellsRef = useRef(null) @@ -105,8 +107,15 @@ export const useNotebookPersistence = ({ const bufferIdRef = useRef(bufferId) bufferIdRef.current = bufferId + const flushRefreshSnapshotsRef = useRef(flushRefreshSnapshots) + flushRefreshSnapshotsRef.current = flushRefreshSnapshots + const flushPending = useCallback(() => { if (preview) return + // The refresh engine throttles its result-snapshot writes; a reload + // inside that window must not swallow the last frame. IndexedDB puts + // started during pagehide generally complete. + flushRefreshSnapshotsRef.current() if (persistTimeoutRef.current === null) return window.clearTimeout(persistTimeoutRef.current) persistTimeoutRef.current = null diff --git a/src/store/notebook.test.ts b/src/store/notebook.test.ts index 1e33776a6..10dae7372 100644 --- a/src/store/notebook.test.ts +++ b/src/store/notebook.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest" import { dropLegacyChartConfigs, migrateCellName, - migrateLegacyAutoRefresh, + migrateImplicitChartAutoRefresh, migrateLegacyCellNames, type AutoRefresh, type NotebookCell, @@ -56,164 +56,53 @@ describe("migrateCellName", () => { }) }) -describe("migrateLegacyAutoRefresh", () => { - const overrides = (state: NotebookViewState): string[] => - state.cells.filter((c) => c.autoRefresh !== undefined).map((c) => c.id) - - it("adopts the shared cadence as the notebook default when every chart agrees", () => { - // Given 10 legacy charts the user had all turned Off - const state: NotebookViewState = { - cells: Array.from({ length: 10 }, (_, i) => chart(`c${i}`, false)), - } - - // When the notebook loads - const result = migrateLegacyAutoRefresh(state) - - // Then the notebook reads Off and no chart claims to override it - expect(result.settings?.autoRefreshDefault).toBe(false) - expect(overrides(result)).toEqual([]) - }) - - it("keeps all-adaptive charts on Auto and drops their phantom overrides", () => { - // Given legacy AI charts, each persisted with an explicit true +describe("migrateImplicitChartAutoRefresh", () => { + it("stamps an explicit Auto onto implicit charts so they keep polling under the Off fallback", () => { + // Given a legacy notebook whose charts polled through the implicit fallback const state: NotebookViewState = { - cells: [chart("a", true), chart("b", true), chart("c", true)], + cells: [chart("a"), chart("b", "5s"), cell({ id: "r", mode: "run" })], } // When the notebook loads - const result = migrateLegacyAutoRefresh(state) - - // Then the notebook reads Auto and nothing claims to override it - expect(result.settings?.autoRefreshDefault).toBe(true) - expect(overrides(result)).toEqual([]) - }) + const result = migrateImplicitChartAutoRefresh(state) - it("adopts a shared fixed interval", () => { - const state: NotebookViewState = { - cells: [chart("a", "5s"), chart("b", "5s")], - } - const result = migrateLegacyAutoRefresh(state) - expect(result.settings?.autoRefreshDefault).toBe("5s") - expect(overrides(result)).toEqual([]) + // Then only the implicit chart gains the stamp; explicit and run cells + // stay untouched + expect(result.cells[0].autoRefresh).toBe(true) + expect(result.cells[1].autoRefresh).toBe("5s") + expect(result.cells[2].autoRefresh).toBeUndefined() }) - it("keeps an override only where a chart diverges from the resulting default", () => { - // Given legacy adaptive charts mixed with charts the user turned Off - const state: NotebookViewState = { - cells: [ - chart("legacy1", true), - chart("legacy2", true), - chart("off1", false), - chart("off2", false), - ], - } - - // When the notebook loads - const result = migrateLegacyAutoRefresh(state) + it("never synthesizes a notebook default", () => { + // Given an untouched chart-only notebook + const result = migrateImplicitChartAutoRefresh({ cells: [chart("a")] }) - // Then the default falls back to Auto and only the real divergence remains - expect(result.settings?.autoRefreshDefault).toBe(true) - expect(overrides(result)).toEqual(["off1", "off2"]) + // Then the chart is stamped but the notebook stays unconfigured + expect(result.cells[0].autoRefresh).toBe(true) + expect(result.settings?.autoRefreshDefault).toBeUndefined() }) - it("marks every chart an override when they diverge with no adaptive majority", () => { + it("yields to a stored notebook default so implicit charts inherit it", () => { + // Given a notebook whose owner chose a notebook-wide cadence const state: NotebookViewState = { - cells: [chart("a", false), chart("b", "5s")], - } - const result = migrateLegacyAutoRefresh(state) - expect(result.settings?.autoRefreshDefault).toBe(true) - expect(overrides(result)).toEqual(["a", "b"]) - }) - - it("clears a dormant adaptive key on a run cell so it cannot inflate the count", () => { - // Given a cell that carried an override before switching out of draw mode - const state: NotebookViewState = { - cells: [cell({ id: "a", mode: "run", autoRefresh: true })], - } - - // Then the invisible key is gone, but a diverging one survives the switch - expect(overrides(migrateLegacyAutoRefresh(state))).toEqual([]) - expect( - overrides( - migrateLegacyAutoRefresh({ - cells: [cell({ id: "a", mode: "run", autoRefresh: "5s" })], - }), - ), - ).toEqual(["a"]) - }) - - it("only decides from charts, ignoring a dormant run-cell value", () => { - // Given every chart Off and an unrelated dormant key on a run cell - const state: NotebookViewState = { - cells: [ - chart("a", false), - chart("b", false), - cell({ id: "r", mode: "run", autoRefresh: "1m" }), - ], - } - - // Then the charts still set the default; the run cell does not vote - const result = migrateLegacyAutoRefresh(state) - expect(result.settings?.autoRefreshDefault).toBe(false) - expect(overrides(result)).toEqual(["r"]) - }) - - it("stamps the default it resolved, so a later override is never mistaken for legacy data", () => { - // Given an untouched notebook — the one shape a deliberate override could - // otherwise be misread as legacy - const first = migrateLegacyAutoRefresh({ cells: [chart("a")] }) - expect(first.settings?.autoRefreshDefault).toBe(true) - - // When the user sets that single chart to Off and it loads again - const overridden: NotebookViewState = { - ...first, - cells: [{ ...first.cells[0], autoRefresh: false }], - } - const reloaded = migrateLegacyAutoRefresh(overridden) - - // Then the override survives instead of collapsing into the default - expect(reloaded.cells[0].autoRefresh).toBe(false) - expect(reloaded.settings?.autoRefreshDefault).toBe(true) - }) - - it("never touches a notebook that already has a stored default", () => { - // Given a post-upgrade notebook where the pinned cell is deliberate - const state: NotebookViewState = { - cells: [chart("a", true), chart("b", "5s")], + cells: [chart("a")], settings: { autoRefreshDefault: "30s" }, } // Then the migration leaves it exactly as-is - expect(migrateLegacyAutoRefresh(state)).toBe(state) + expect(migrateImplicitChartAutoRefresh(state)).toBe(state) }) - it("is idempotent", () => { + it("returns the same state when nothing needs the stamp, and is idempotent", () => { + // Given only explicit charts and run cells const state: NotebookViewState = { - cells: [chart("a", false), chart("b", false)], + cells: [chart("a", false), cell({ id: "r", mode: "run" })], } - const once = migrateLegacyAutoRefresh(state) - expect(migrateLegacyAutoRefresh(once)).toBe(once) - }) - - it("preserves what every chart actually polls at", () => { - // Given a mix of stored, absent, and diverging values - const state: NotebookViewState = { - cells: [ - chart("a", true), - chart("b"), - chart("c", false), - chart("d", "5s"), - ], - } - const before = state.cells.map((c) => c.autoRefresh ?? true) - - // When migrated, each cell resolves against the new default - const result = migrateLegacyAutoRefresh(state) - const fallback = result.settings?.autoRefreshDefault ?? true - const after = result.cells.map((c) => c.autoRefresh ?? fallback) + expect(migrateImplicitChartAutoRefresh(state)).toBe(state) - // Then every chart polls exactly as it did before - expect(after).toEqual(before) + // And a stamped notebook does not change on a second pass + const stamped = migrateImplicitChartAutoRefresh({ cells: [chart("b")] }) + expect(migrateImplicitChartAutoRefresh(stamped)).toBe(stamped) }) }) diff --git a/src/store/notebook.ts b/src/store/notebook.ts index dfab75995..e7cde199e 100644 --- a/src/store/notebook.ts +++ b/src/store/notebook.ts @@ -104,6 +104,7 @@ export type SingleQueryResult = export type CellResult = { results: SingleQueryResult[] activeResultIndex: number + activeStatementKey?: string error?: string timestamp: number script?: { @@ -190,26 +191,32 @@ export const migrateLegacyCellNames = ( ? { ...state, cells: state.cells.map(migrateCellName) } : state -export const migrateLegacyAutoRefresh = ( +// Nothing polls unless the cell or the notebook says so — but charts are born +// live: entering draw mode stamps an explicit Auto onto the cell. A stored +// notebook default (even Off) is the user's notebook-wide intent, so the +// stamp yields to it and the chart inherits instead. +export const chartAutoRefreshStamp = ( + cell: Pick, + autoRefreshDefault: AutoRefresh | undefined, +): Partial => + cell.mode === "draw" && + cell.autoRefresh === undefined && + autoRefreshDefault === undefined + ? { autoRefresh: true } + : {} + +// Charts drawn before the uniform-Off fallback polled through an implicit +// per-view Auto. The stamp makes that liveness explicit so they keep polling; +// grids never polled, and stay off. No notebook default is ever synthesized. +export const migrateImplicitChartAutoRefresh = ( state: NotebookViewState, ): NotebookViewState => { - if (state.settings?.autoRefreshDefault !== undefined) return state - const shown = state.cells - .filter((cell) => cell.mode === "draw") - .map((cell) => cell.autoRefresh ?? true) - const autoRefreshDefault = - shown.length > 0 && shown.every((value) => value === shown[0]) - ? shown[0] - : true - const cells = state.cells.map((cell) => { - if (cell.autoRefresh !== autoRefreshDefault) return cell - const next = { ...cell } - delete next.autoRefresh - return next - }) - return { - ...state, - cells, - settings: { ...state.settings, autoRefreshDefault }, - } + const autoRefreshDefault = state.settings?.autoRefreshDefault + const needsStamp = (cell: NotebookCell) => + Object.keys(chartAutoRefreshStamp(cell, autoRefreshDefault)).length > 0 + if (!state.cells.some(needsStamp)) return state + const cells = state.cells.map((cell) => + needsStamp(cell) ? { ...cell, autoRefresh: true as const } : cell, + ) + return { ...state, cells } } diff --git a/src/store/notebookResults.test.ts b/src/store/notebookResults.test.ts index cbaa6a603..51d217159 100644 --- a/src/store/notebookResults.test.ts +++ b/src/store/notebookResults.test.ts @@ -114,10 +114,12 @@ describe("notebookResults", () => { it("updateCellSnapshotActiveIndex updates an existing record and skips a missing one", async () => { await saveCellSnapshot(snap(1, "c1", 100)) - await updateCellSnapshotActiveIndex(1, "c1", 3) - expect((await loadCellSnapshot(1, "c1"))?.activeResultIndex).toBe(3) + await updateCellSnapshotActiveIndex(1, "c1", 3, "select 30") + const updated = await loadCellSnapshot(1, "c1") + expect(updated?.activeResultIndex).toBe(3) + expect(updated?.activeStatementKey).toBe("select 30") - await updateCellSnapshotActiveIndex(1, "ghost", 3) + await updateCellSnapshotActiveIndex(1, "ghost", 3, "select 30") expect(await loadCellSnapshot(1, "ghost")).toBeUndefined() }) @@ -191,7 +193,7 @@ describe("notebookResults", () => { expect(savedAt).toBeGreaterThanOrEqual(copiedAtLeast) // And changing the duplicate leaves the source snapshot untouched - await updateCellSnapshotActiveIndex(2, "new-run", 0) + await updateCellSnapshotActiveIndex(2, "new-run", 0, "select 10") await deleteCellSnapshot(2, "new-draw") expect(await loadCellSnapshot(1, "run")).toMatchObject({ activeResultIndex: 1, diff --git a/src/store/notebookResults.ts b/src/store/notebookResults.ts index 8e5f7275c..85f1eb073 100644 --- a/src/store/notebookResults.ts +++ b/src/store/notebookResults.ts @@ -7,13 +7,24 @@ import type { CellResult, SingleQueryResult } from "./notebook" // capped at the notebook row/byte limits before it is saved. // `activeResultIndex` and `script` restore the tab the user was viewing and the // script summary; records written before these fields existed omit them, so -// readers must default (index 0, no summary). +// readers must default (index 0, no summary). `activeStatementKey` is the +// content identity of the active tab (normalized text + occurrence); +// `activeResultIndex` stays only as the read fallback for older records. +// `refreshErrors` restores per-statement refresh failures, so a reload never +// hides a failed refresh; records without the field restore no errors. +export type SnapshotRefreshError = { + statementKey: string + message: string +} + export type NotebookResultSnapshot = { bufferId: number cellId: string results: SingleQueryResult[] savedAt: number activeResultIndex?: number + activeStatementKey?: string + refreshErrors?: SnapshotRefreshError[] script?: CellResult["script"] } @@ -32,9 +43,10 @@ export const updateCellSnapshotActiveIndex = ( bufferId: number, cellId: string, activeResultIndex: number, + activeStatementKey: string, ): Promise => db.notebook_results - .update([bufferId, cellId], { activeResultIndex }) + .update([bufferId, cellId], { activeResultIndex, activeStatementKey }) .then(() => undefined) // Index-only read: snapshot payloads are never deserialized. diff --git a/src/utils/ai/notebookSnapshot.test.ts b/src/utils/ai/notebookSnapshot.test.ts index 4a084a712..c269d3684 100644 --- a/src/utils/ai/notebookSnapshot.test.ts +++ b/src/utils/ai/notebookSnapshot.test.ts @@ -236,7 +236,7 @@ describe("buildSnapshot", () => { } }) - it("always populates auto_refresh_default — effective true when unset, the stored value when set", async () => { + it("reports auto_refresh_default only when the notebook configured one", async () => { const cells = [sql("a", "SELECT 1")] const unsetId = await seedNotebook({ cells }) const storedId = await seedNotebook({ @@ -255,8 +255,10 @@ describe("buildSnapshot", () => { stored?.status === "ok" && off?.status === "ok" ) { - // The agent always sees one concrete effective value. - expect(unset.auto_refresh_default).toBe(true) + // Absence stays observable: with no configured default, charts poll on + // Auto and grids do not poll at all — one synthesized value would hide + // that split. + expect(unset.auto_refresh_default).toBeUndefined() expect(stored.auto_refresh_default).toBe("30s") expect(off.auto_refresh_default).toBe(false) } else { diff --git a/src/utils/ai/notebookSnapshot.ts b/src/utils/ai/notebookSnapshot.ts index 98eb8ffcc..b01a92b59 100644 --- a/src/utils/ai/notebookSnapshot.ts +++ b/src/utils/ai/notebookSnapshot.ts @@ -1,4 +1,7 @@ -import { getController } from "../notebooks/notebookController" +import { + getController, + type CellRefreshView, +} from "../notebooks/notebookController" import { enqueueBufferTask } from "../notebooks/notebookBufferQueue" import { readNotebookBufferMeta } from "../notebooks/notebookDexieView" import { NotebookToolError } from "../notebooks/notebookToolError" @@ -50,6 +53,11 @@ export type NotebookContextCell = { chart_config?: ChartConfigWire last_run_status?: RunStatus last_run_error_summary?: string + // Live-only: present for the mounted notebook alone. Absence never means + // "not refreshing" or "not blocked". + refreshing?: true + last_refresh_error?: string + auto_refresh_blocked?: "contains_write" grid?: { x: number; y: number; w: number; h: number } } @@ -59,7 +67,8 @@ export type NotebookContextSnapshot = buffer_id: number label: string layout_mode: "list" | "grid" - auto_refresh_default: AutoRefresh + // Absent when the notebook has no configured default. + auto_refresh_default?: AutoRefresh maximized_cell_id: string | null variables?: Array<{ name: string; value: string }> cells: NotebookContextCell[] @@ -117,15 +126,42 @@ const lastRunSummary = ( return { last_run_status: status } } +// The refresh channel is separate from run history: `last_run_status` stays +// the outcome of the last completed RUN, while these describe the last +// refresh round. Both grid and chart cells report here. +const refreshFields = ( + view: CellRefreshView | undefined, +): Pick< + NotebookContextCell, + "refreshing" | "last_refresh_error" | "auto_refresh_blocked" +> => { + if (!view) return {} + return { + ...(view.refreshing ? { refreshing: true as const } : {}), + ...(view.lastRefreshError !== undefined + ? { + last_refresh_error: sanitizeForPromptContext( + truncate(view.lastRefreshError, ERROR_MAX), + ), + } + : {}), + ...(view.autoRefreshBlocked !== undefined + ? { auto_refresh_blocked: view.autoRefreshBlocked } + : {}), + } +} + const buildCell = ( cell: NotebookCell, gridByCellId: Map, layoutMode: "list" | "grid", + refreshState: ReadonlyMap | undefined, ): NotebookContextCell => { const out: NotebookContextCell = { id: cell.id, preview: preview(cell.value), ...lastRunSummary(cell), + ...refreshFields(refreshState?.get(cell.id)), } if (cell.value.length > PREVIEW_MAX) { out.preview_truncated = true @@ -171,6 +207,7 @@ export const buildSnapshot = async ( return { status: "archived", buffer_id: bufferId, label: meta.label } } const view = controller ? await controller.readView() : meta.view + const refreshState = controller?.readRefreshState?.() const cells: NotebookCell[] = view.cells const settings: NotebookSettings = view.settings ?? {} const maximizedCellId = view.maximizedCellId ?? null @@ -185,9 +222,15 @@ export const buildSnapshot = async ( buffer_id: bufferId, label: meta.label, layout_mode: layoutMode, - auto_refresh_default: settings.autoRefreshDefault ?? true, maximized_cell_id: maximizedCellId, - cells: cells.map((c) => buildCell(c, gridByCellId, layoutMode)), + cells: cells.map((c) => + buildCell(c, gridByCellId, layoutMode, refreshState), + ), + } + // Absence stays observable: with no configured default, charts poll on Auto + // and grids do not poll at all. + if (settings.autoRefreshDefault !== undefined) { + out.auto_refresh_default = settings.autoRefreshDefault } const variables = normalizeVariables(settings.variables) if (variables.length > 0) { @@ -219,7 +262,9 @@ export const formatSnapshot = (snap: NotebookContextSnapshot): string => { lines.push(` buffer_id: ${snap.buffer_id}`) lines.push(` label: ${JSON.stringify(sanitizeForPromptContext(snap.label))}`) lines.push(` layout_mode: ${snap.layout_mode}`) - lines.push(` auto_refresh_default: ${snap.auto_refresh_default}`) + if (snap.auto_refresh_default !== undefined) { + lines.push(` auto_refresh_default: ${snap.auto_refresh_default}`) + } lines.push( ` maximized_cell_id: ${ snap.maximized_cell_id ? JSON.stringify(snap.maximized_cell_id) : "null" @@ -266,6 +311,13 @@ export const formatSnapshot = (snap: NotebookContextSnapshot): string => { c.last_run_error_summary, )}`, ) + if (c.refreshing) lines.push(` refreshing: true`) + if (c.last_refresh_error) + lines.push( + ` last_refresh_error: ${JSON.stringify(c.last_refresh_error)}`, + ) + if (c.auto_refresh_blocked) + lines.push(` auto_refresh_blocked: ${c.auto_refresh_blocked}`) if (c.grid) { lines.push( ` grid: { x: ${c.grid.x}, y: ${c.grid.y}, w: ${c.grid.w}, h: ${c.grid.h} }`, @@ -367,6 +419,10 @@ export type NotebookCellSummary = { type?: "sql" | "markdown" mode?: "run" | "draw" last_run_status?: RunStatus + // Live-only (mounted notebook); see NotebookContextCell. + refreshing?: true + last_refresh_error?: string + auto_refresh_blocked?: "contains_write" } export type NotebookCellDetails = { @@ -383,9 +439,16 @@ export type NotebookCellDetails = { chart_config?: ChartConfigWire last_run_status?: RunStatus last_run_error?: string + // Live-only (mounted notebook); see NotebookContextCell. + refreshing?: true + last_refresh_error?: string + auto_refresh_blocked?: "contains_write" } -export const summarizeCells = (cells: NotebookCell[]): NotebookCellSummary[] => +export const summarizeCells = ( + cells: NotebookCell[], + refreshState?: ReadonlyMap, +): NotebookCellSummary[] => cells.map((cell) => { const summary: NotebookCellSummary = { id: cell.id, @@ -395,6 +458,7 @@ export const summarizeCells = (cells: NotebookCell[]): NotebookCellSummary[] => : `${cell.value.slice(0, 117)}...`, position: cell.position, last_run_status: runStatusOf(cell).status, + ...refreshFields(refreshState?.get(cell.id)), } if (cell.name) summary.name = cell.name if (cell.type === "markdown") summary.type = "markdown" @@ -407,6 +471,7 @@ export const serializeCell = ( cellId: string, bufferId: number, getFullContent: boolean, + refreshState?: ReadonlyMap, ): NotebookCellDetails => { const cell = cells.find((c) => c.id === cellId) if (!cell) { @@ -430,6 +495,7 @@ export const serializeCell = ( position: cell.position, last_run_status: run.status, last_run_error: run.error, + ...refreshFields(refreshState?.get(cell.id)), } if (truncated) { out.truncated = true diff --git a/src/utils/ai/runStatus.test.ts b/src/utils/ai/runStatus.test.ts index c903b73b8..9a487a186 100644 --- a/src/utils/ai/runStatus.test.ts +++ b/src/utils/ai/runStatus.test.ts @@ -159,12 +159,35 @@ describe("cellRunStatus", () => { expect(getCellRunStatus(undefined)).toEqual({ status: "none" }) }) - it("prefers the live result over a stale persisted status", () => { + it("prefers the recorded run history over a refresh-produced frame", () => { + // Given a cell whose recorded run succeeded while a later refresh left an + // error result in the frame + // Then the recorded outcome wins — refresh settles never rewrite history expect( getCellRunStatus({ result: { results: [{ type: "error", error: "x" }] }, lastRunStatus: "success", }), + ).toEqual({ status: "success" }) + }) + + it("live execution always shows through as running", () => { + // Given a recorded outcome with a new run in flight + expect( + getCellRunStatus({ + result: { results: [{ type: "running" }] }, + lastRunStatus: "error", + lastRunError: "old", + }), + ).toEqual({ status: "running" }) + }) + + it("derives from the result only when no history was recorded", () => { + // Given a pre-stamping record: a result with no lastRunStatus + expect( + getCellRunStatus({ + result: { results: [{ type: "error", error: "x" }] }, + }), ).toEqual({ status: "error", error: "x" }) }) }) diff --git a/src/utils/ai/runStatus.ts b/src/utils/ai/runStatus.ts index 6ef629e87..f275d39e6 100644 --- a/src/utils/ai/runStatus.ts +++ b/src/utils/ai/runStatus.ts @@ -30,6 +30,9 @@ export const deriveRunStatusFromResults = ( return { status: "none" } } +// Read order: live execution first (pending slots), then the RECORDED run +// history (stamped by run commits only — refresh settles never touch it), +// then result derivation as the fallback for records that predate stamping. export const getCellRunStatus = ( cell: | { @@ -42,11 +45,18 @@ export const getCellRunStatus = ( | null | undefined, ): { status: RunStatus; error?: string } => { - if (cell?.result) return deriveRunStatusFromResults(cell.result.results) - return { - status: cell?.lastRunStatus ?? "none", - ...(cell?.lastRunError ? { error: cell.lastRunError } : {}), + const results = cell?.result?.results + if (results?.some((r) => r.type === "running" || r.type === "queued")) { + return { status: "running" } + } + if (cell?.lastRunStatus !== undefined) { + return { + status: cell.lastRunStatus, + ...(cell.lastRunError ? { error: cell.lastRunError } : {}), + } } + if (cell?.result) return deriveRunStatusFromResults(cell.result.results) + return { status: "none" } } export const createRunStatus = ( diff --git a/src/utils/ai/shared.notebookTools.test.ts b/src/utils/ai/shared.notebookTools.test.ts index c4f5ac78b..c1d05fabc 100644 --- a/src/utils/ai/shared.notebookTools.test.ts +++ b/src/utils/ai/shared.notebookTools.test.ts @@ -28,6 +28,14 @@ import { saveCellSnapshot, } from "../../store/notebookResults" import { __resetNotebookBufferQueuesForTests } from "../notebooks/notebookBufferQueue" +import { + checkStatementsForAutoRun, + checkStatementsForExecution, + classifyStatements, + clearStatementClassCache, + type RunCellGate, +} from "../tools/permissions" +import type { ValidateQueryResult } from "../questdb/types" import { dispatchMCPTool } from "../mcp/dispatchMCPTool" import { EXPECTED_BRIDGE_VERSION } from "../mcp/protocolVersion" import type { ToolExecutionContext } from "./shared" @@ -54,6 +62,9 @@ const mountLive = ( signal?: AbortSignal, sql?: string, ) => Promise + // When set, the harness runner honors the gate contract the real runner + // implements: classify the checked SQL, deny/skip on the barrier. + validate?: (sql: string) => Promise // Fires on each readView — lets a test simulate a user edit racing a read. onRead?: () => void } = {}, @@ -66,10 +77,42 @@ const mountLive = ( focusedCellId: null, }, } - const runCell = + const inner = opts.runCell ?? (() => Promise.resolve({ success: true, queryCount: 1, results: ["success"] })) + const runCell = async ( + cellId: string, + signal?: AbortSignal, + sql?: string, + gate?: RunCellGate, + ): Promise => { + if (gate !== undefined && sql !== undefined && opts.validate) { + const stmts = await classifyStatements(sql, opts.validate) + if (gate.kind === "explicit") { + const decision = checkStatementsForExecution(stmts, gate.permissions) + if (!decision.granted) { + return { + success: false, + queryCount: 0, + results: [], + denied: decision.reason, + } + } + } else { + const decision = checkStatementsForAutoRun(stmts) + if (decision.action === "skip") { + return { + success: false, + queryCount: 0, + results: [], + skipped: decision.reason, + } + } + } + } + return inner(cellId, signal, sql) + } const controller: NotebookController = { bufferId, kind: "live", @@ -147,6 +190,7 @@ beforeEach(async () => { __resetNotebookControllerForTests() __resetNotebookAIBridgeForTests() __resetNotebookBufferQueuesForTests() + clearStatementClassCache() await db.buffers.clear() await db.notebook_results.clear() // A backing Dexie row so buildSnapshot (get_notebook_state) can read meta. @@ -304,6 +348,7 @@ describe("dispatchTool — notebook tools (happy path)", () => { expect.any(String), undefined, undefined, + undefined, ) expect(JSON.parse(res.content)).toMatchObject({ ran: false, @@ -541,6 +586,47 @@ describe("dispatchTool — notebook tools (happy path)", () => { expect(state.parts.settings.autoRefreshDefault).toBe(false) }) + it("set_notebook_autorefresh with reset_cell_overrides clears every per-cell override atomically", async () => { + // Given a dashboard where every cell carries its own interval + const { state } = mountLive(1, [ + cell("grid1", "SELECT 1", { autoRefresh: "5s" }), + cell("grid2", "SELECT 2", { autoRefresh: false }), + cell("chart1", "SELECT 3", { autoRefresh: true, mode: "draw" }), + cell("plain", "SELECT 4"), + ]) + // When the agent sets a notebook default and asks for the reset + await dispatchTool( + "set_notebook_autorefresh", + { buffer_id: 1, value: "30s", reset_cell_overrides: true }, + makeClient(), + noopStatus, + ) + // Then the default is stored and no override key survives — + // every cell now inherits 30s + expect(state.parts.settings.autoRefreshDefault).toBe("30s") + for (const id of ["grid1", "grid2", "chart1", "plain"]) { + const updated = cellById(state, id) + expect(updated && "autoRefresh" in updated).toBe(false) + } + }) + + it("set_notebook_autorefresh without reset_cell_overrides keeps per-cell overrides winning", async () => { + // Given a cell pinned to its own interval + const { state } = mountLive(1, [ + cell("c", "SELECT 1", { autoRefresh: "5s" }), + ]) + // When the agent sets only the notebook default + await dispatchTool( + "set_notebook_autorefresh", + { buffer_id: 1, value: "30s", reset_cell_overrides: null }, + makeClient(), + noopStatus, + ) + // Then the override stays and still wins over the new default + expect(state.parts.settings.autoRefreshDefault).toBe("30s") + expect(cellById(state, "c")?.autoRefresh).toBe("5s") + }) + it("set_notebook_autorefresh rejects a token outside the allowed set", async () => { const { state } = mountLive(1, [cell("c")]) const res = await dispatchTool( @@ -553,6 +639,117 @@ describe("dispatchTool — notebook tools (happy path)", () => { expect(state.parts.settings.autoRefreshDefault).toBeUndefined() }) + it("set_cell_mode draw stamps an explicit Auto so the chart is born polling", async () => { + // Given a run cell in a notebook with no auto-refresh default + const { state } = mountLive(1, [cell("c", "SELECT 1")]) + + // When the agent switches it to draw mode + await dispatchTool( + "set_cell_mode", + { buffer_id: 1, cell_id: "c", mode: "draw" }, + makeClient(), + noopStatus, + { grantSchemaAccess: true, read: true, write: false }, + vi.fn().mockResolvedValue({ + query: "SELECT 1", + columns: [{ name: "1", type: "INT" }], + timestamp: 0, + }), + ) + + // Then the chart carries the stamp as an ordinary per-cell value + expect(cellById(state, "c")?.mode).toBe("draw") + expect(cellById(state, "c")?.autoRefresh).toBe(true) + }) + + it("set_cell_mode draw yields to a stored notebook default instead of stamping", async () => { + // Given a notebook whose owner chose a notebook-wide cadence + const { state } = mountLive(1, [cell("c", "SELECT 1")], { + settings: { autoRefreshDefault: "30s" }, + }) + + // When the agent switches the cell to draw mode + await dispatchTool( + "set_cell_mode", + { buffer_id: 1, cell_id: "c", mode: "draw" }, + makeClient(), + noopStatus, + { grantSchemaAccess: true, read: true, write: false }, + vi.fn().mockResolvedValue({ + query: "SELECT 1", + columns: [{ name: "1", type: "INT" }], + timestamp: 0, + }), + ) + + // Then no stamp lands — the chart inherits the notebook default + expect(cellById(state, "c")?.mode).toBe("draw") + expect(cellById(state, "c")?.autoRefresh).toBeUndefined() + }) + + it("apply_notebook_state stamps draw cells sent without auto_refresh", async () => { + // Given a notebook with no auto-refresh default + const { state } = mountLive(1, [cell("a", "SELECT 1")]) + + // When an apply composes a chart without a per-cell auto_refresh + await dispatchTool( + "apply_notebook_state", + { + buffer_id: 1, + cells: [ + { id: "a", preserve_value: true }, + { + value: "SELECT ts, price FROM trades", + mode: "draw", + chart_config: { + x_column: "ts", + queries: [{ type: "line", y_columns: ["price"] }], + }, + }, + ], + }, + makeClient(), + noopStatus, + ) + + // Then the chart is born polling and the grid cell stays unstamped + const chart = state.parts.cells.find((c) => c.mode === "draw") + expect(chart?.autoRefresh).toBe(true) + expect(cellById(state, "a")?.autoRefresh).toBeUndefined() + }) + + it("apply_notebook_state does not stamp when the same apply sets a default", async () => { + // Given an apply that configures the notebook default and a chart together + const { state } = mountLive(1, [cell("a", "SELECT 1")]) + + // When the apply carries auto_refresh_default alongside the draw cell + await dispatchTool( + "apply_notebook_state", + { + buffer_id: 1, + auto_refresh_default: "30s", + cells: [ + { id: "a", preserve_value: true }, + { + value: "SELECT ts, price FROM trades", + mode: "draw", + chart_config: { + x_column: "ts", + queries: [{ type: "line", y_columns: ["price"] }], + }, + }, + ], + }, + makeClient(), + noopStatus, + ) + + // Then the chart inherits the default instead of carrying a stamp + const chart = state.parts.cells.find((c) => c.mode === "draw") + expect(chart?.autoRefresh).toBeUndefined() + expect(state.parts.settings.autoRefreshDefault).toBe("30s") + }) + it("set_cell_name sets the cell name", async () => { const { state } = mountLive(1, [cell("c")]) await dispatchTool( @@ -1744,27 +1941,37 @@ describe("dispatchTool — notebook tools (happy path)", () => { }), ) expect(res.is_error).toBeFalsy() - // The exact authorization-checked SQL is threaded to execution. - expect(runCell).toHaveBeenCalledWith("c", undefined, "SELECT 1") + // The exact authorization-checked SQL is threaded to execution, with the + // permission gate the runner's barrier enforces. + expect(runCell).toHaveBeenCalledWith("c", undefined, "SELECT 1", { + kind: "explicit", + permissions: { grantSchemaAccess: false, read: false, write: false }, + }) }) it("run_cell executes the checked SQL, not a value swapped in during the validate round-trip", async () => { - // A run-mode cell starts at a read query the gate will allow. - const { state, runCell } = mountLive(1, [cell("c", "SELECT 1")]) // Simulate a concurrent ungated update_cell landing while run_cell awaits // the /sql/validate round-trip: the live cell value flips to a write // between classification and execution. + const state0: { swap?: () => void } = {} const validateSql = vi.fn((sql: string) => { - state.parts = { - ...state.parts, - cells: [cell("c", "DROP TABLE t")], - } + state0.swap?.() return Promise.resolve({ query: sql, columns: [{ name: "c1", type: "LONG" }], timestamp: -1, }) }) + // A run-mode cell starts at a read query the gate will allow. + const { state, runCell } = mountLive(1, [cell("c", "SELECT 1")], { + validate: validateSql, + }) + state0.swap = () => { + state.parts = { + ...state.parts, + cells: [cell("c", "DROP TABLE t")], + } + } const res = await dispatchTool( "run_cell", { buffer_id: 1, cell_id: "c" }, @@ -1776,8 +1983,12 @@ describe("dispatchTool — notebook tools (happy path)", () => { expect(res.is_error).toBeFalsy() // The race is real: the live value did change mid-flight... expect(cellById(state, "c")?.value).toBe("DROP TABLE t") - // ...but the executor was handed the checked SELECT, never the DROP. - expect(runCell).toHaveBeenCalledWith("c", undefined, "SELECT 1") + // ...but the runner was handed the checked SELECT, never the DROP — its + // barrier classifies and executes that exact string. + expect(runCell).toHaveBeenCalledWith("c", undefined, "SELECT 1", { + kind: "explicit", + permissions: { grantSchemaAccess: false, read: true, write: false }, + }) }) it("run_cell with no gate leaves execution to re-read the live cell", async () => { @@ -1790,7 +2001,7 @@ describe("dispatchTool — notebook tools (happy path)", () => { ) expect(res.is_error).toBeFalsy() // No gate ran, so no SQL is pinned — the executor re-reads the cell. - expect(runCell).toHaveBeenCalledWith("c", undefined, undefined) + expect(runCell).toHaveBeenCalledWith("c", undefined, undefined, undefined) }) it("allows add_cell with run=true and SELECT when read and write are both denied", async () => { @@ -1813,20 +2024,27 @@ describe("dispatchTool — notebook tools (happy path)", () => { }) it("skips add_cell run when the cell contains DDL/DML — the cell is still added", async () => { - const { state, runCell } = mountLive(1) + const validate = vi.fn().mockResolvedValue({ queryType: "INSERT" }) + const { state, runCell } = mountLive(1, [], { validate }) const res = await dispatchTool( "add_cell", { buffer_id: 1, sql: "INSERT INTO t VALUES (1)", run: true }, makeClient(), noopStatus, { grantSchemaAccess: true, read: true, write: true }, - vi.fn().mockResolvedValue({ queryType: "INSERT" }), + validate, ) expect(res.is_error).toBeFalsy() expect(cellById(state, cellIds(state)[0])?.value).toBe( "INSERT INTO t VALUES (1)", ) - expect(runCell).not.toHaveBeenCalled() + // The runner's barrier decides the skip — dispatch hands it the gate. + expect(runCell).toHaveBeenCalledWith( + expect.any(String), + undefined, + "INSERT INTO t VALUES (1)", + { kind: "autoRun" }, + ) const parsed = JSON.parse(res.content) as { cellId: string ran: boolean @@ -1840,23 +2058,29 @@ describe("dispatchTool — notebook tools (happy path)", () => { expect(parsed.note).toMatch(/run_cell/) }) - // SAFETY PRECONDITION — "agent flows never auto-run DDL/DML". The guard lives - // inside `if (perms && validateSql)`, so it protects the flow ONLY because - // every production call site threads BOTH args (anthropicProvider, + // SAFETY PRECONDITION — "agent flows never auto-run DDL/DML". The gate is + // threaded ONLY inside `if (perms && validateSql)`, so it protects the flow + // because every production call site threads BOTH args (anthropicProvider, // openaiProvider, openaiChatCompletionsProvider, dispatchMCPTool). This pins // both halves so a caller that drops the gate args — or a refactor of that // condition — fails loudly here instead of silently auto-running a write. it("auto-run write protection depends entirely on the gate args", async () => { - const gate = mountLive(1, [], { runCell: okRun }) + const validate = vi.fn().mockResolvedValue({ queryType: "INSERT" }) + const gate = mountLive(1, [], { runCell: okRun, validate }) const gated = await dispatchTool( "add_cell", { buffer_id: 1, sql: "INSERT INTO t VALUES (1)", run: true }, makeClient(), noopStatus, { grantSchemaAccess: true, read: true, write: true }, - vi.fn().mockResolvedValue({ queryType: "INSERT" }), + validate, + ) + expect(gate.runCell).toHaveBeenCalledWith( + expect.any(String), + undefined, + "INSERT INTO t VALUES (1)", + { kind: "autoRun" }, ) - expect(gate.runCell).not.toHaveBeenCalled() expect(JSON.parse(gated.content)).toMatchObject({ ran: false, skipped: true, @@ -1869,7 +2093,12 @@ describe("dispatchTool — notebook tools (happy path)", () => { makeClient(), noopStatus, ) - expect(ungate.runCell).toHaveBeenCalled() + expect(ungate.runCell).toHaveBeenCalledWith( + expect.any(String), + undefined, + undefined, + undefined, + ) expect(JSON.parse(ungated.content)).toMatchObject({ ran: true }) }) }) @@ -1899,6 +2128,7 @@ describe("dispatchTool — apply_notebook_state auto-run", () => { expect.any(String), undefined, undefined, + undefined, ) const parsed = JSON.parse(res.content) as { runs: Array<{ success: boolean; queryCount?: number; results?: string[] }> @@ -1928,6 +2158,7 @@ describe("dispatchTool — apply_notebook_state auto-run", () => { expect.any(String), undefined, undefined, + undefined, ) }) @@ -2037,7 +2268,8 @@ describe("dispatchTool — apply_notebook_state auto-run", () => { }) it("skips DDL/DML run cells regardless of the write permission", async () => { - const { runCell } = mountLive(1, [], { runCell: okRun }) + const validate = vi.fn().mockResolvedValue({ queryType: "DROP TABLE" }) + const { runCell } = mountLive(1, [], { runCell: okRun, validate }) const res = await dispatchTool( "apply_notebook_state", { @@ -2049,10 +2281,15 @@ describe("dispatchTool — apply_notebook_state auto-run", () => { makeClient(), noopStatus, { grantSchemaAccess: true, read: true, write: false }, - vi.fn().mockResolvedValue({ queryType: "DROP TABLE" }), + validate, ) expect(res.is_error).toBeFalsy() - expect(runCell).not.toHaveBeenCalled() + expect(runCell).toHaveBeenCalledWith( + expect.any(String), + undefined, + "DROP TABLE victim", + { kind: "autoRun" }, + ) const parsed = JSON.parse(res.content) as { runs: Array<{ cellId: string @@ -2067,10 +2304,11 @@ describe("dispatchTool — apply_notebook_state auto-run", () => { }) it("skips DDL/DML cells regardless of run history (writes never auto-run)", async () => { + const validate = vi.fn().mockResolvedValue({ queryType: "INSERT" }) const { runCell } = mountLive( 1, [cell("ins-1", "INSERT INTO t VALUES (1)", { mode: "run" })], - { runCell: okRun }, + { runCell: okRun, validate }, ) const res = await dispatchTool( "apply_notebook_state", @@ -2083,10 +2321,15 @@ describe("dispatchTool — apply_notebook_state auto-run", () => { makeClient(), noopStatus, { grantSchemaAccess: true, read: true, write: true }, - vi.fn().mockResolvedValue({ queryType: "INSERT" }), + validate, ) expect(res.is_error).toBeFalsy() - expect(runCell).not.toHaveBeenCalled() + expect(runCell).toHaveBeenCalledWith( + "ins-1", + undefined, + "INSERT INTO t VALUES (1)", + { kind: "autoRun" }, + ) const parsed = JSON.parse(res.content) as { runs: Array<{ cellId: string @@ -2105,10 +2348,11 @@ describe("dispatchTool — apply_notebook_state auto-run", () => { }) it("skips DDL/DML cells that never ran before (only run_cell executes writes)", async () => { + const validate = vi.fn().mockResolvedValue({ queryType: "INSERT" }) const { runCell } = mountLive( 1, [cell("ins-1", "INSERT INTO t VALUES (1)", { mode: "run" })], - { runCell: okRun }, + { runCell: okRun, validate }, ) const res = await dispatchTool( "apply_notebook_state", @@ -2124,9 +2368,10 @@ describe("dispatchTool — apply_notebook_state auto-run", () => { makeClient(), noopStatus, { grantSchemaAccess: true, read: true, write: true }, - vi.fn().mockResolvedValue({ queryType: "INSERT" }), + validate, ) - expect(runCell).not.toHaveBeenCalled() + const gates = vi.mocked(runCell).mock.calls.map((call) => call[3]) + expect(gates).toEqual([{ kind: "autoRun" }, { kind: "autoRun" }]) const parsed = JSON.parse(res.content) as { runs: Array<{ cellId: string @@ -2161,7 +2406,9 @@ describe("dispatchTool — apply_notebook_state auto-run", () => { { grantSchemaAccess: true, read: true, write: true }, dqlValidate, ) - expect(runCell).toHaveBeenCalledWith("sel-1", undefined, "SELECT 1") + expect(runCell).toHaveBeenCalledWith("sel-1", undefined, "SELECT 1", { + kind: "autoRun", + }) }) it("preserves existing mode when mode is omitted on an existing cell (draw stays draw, run stays run)", async () => { @@ -2198,7 +2445,9 @@ describe("dispatchTool — apply_notebook_state auto-run", () => { ) expect(res.is_error).toBeFalsy() expect(runCell).toHaveBeenCalledTimes(1) - expect(runCell).toHaveBeenCalledWith("run-id", undefined, "SELECT 1") + expect(runCell).toHaveBeenCalledWith("run-id", undefined, "SELECT 1", { + kind: "autoRun", + }) expect(cellById(state, "run-id")?.mode).toBe("run") expect(cellById(state, "draw-id")?.mode).toBe("draw") }) @@ -2578,10 +2827,11 @@ describe("dispatchTool — apply_notebook_state preserve_value", () => { }) it("auto-run skips a preserved write cell, gating on its live SQL", async () => { + const validate = vi.fn().mockResolvedValue({ queryType: "INSERT" }) const { runCell } = mountLive( 1, [cell("ins-1", "INSERT INTO t VALUES (1)", { mode: "run" })], - { runCell: okRun }, + { runCell: okRun, validate }, ) const res = await dispatchTool( "apply_notebook_state", @@ -2594,10 +2844,15 @@ describe("dispatchTool — apply_notebook_state preserve_value", () => { makeClient(), noopStatus, { grantSchemaAccess: true, read: true, write: true }, - vi.fn().mockResolvedValue({ queryType: "INSERT" }), + validate, ) expect(res.is_error).toBeFalsy() - expect(runCell).not.toHaveBeenCalled() + expect(runCell).toHaveBeenCalledWith( + "ins-1", + undefined, + "INSERT INTO t VALUES (1)", + { kind: "autoRun" }, + ) const parsed = JSON.parse(res.content) as { runs: Array<{ cellId: string; skipped?: boolean }> } @@ -2629,6 +2884,8 @@ describe("dispatchTool — apply_notebook_state preserve_value", () => { timestamp: -1, }), ) - expect(runCell).toHaveBeenCalledWith("sel-1", undefined, "SELECT 1") + expect(runCell).toHaveBeenCalledWith("sel-1", undefined, "SELECT 1", { + kind: "autoRun", + }) }) }) diff --git a/src/utils/notebooks/notebookAIBridge.test.ts b/src/utils/notebooks/notebookAIBridge.test.ts index 417445564..e68b457f1 100644 --- a/src/utils/notebooks/notebookAIBridge.test.ts +++ b/src/utils/notebooks/notebookAIBridge.test.ts @@ -488,6 +488,7 @@ describe("createNotebookController — applyNotebookState maximized cell id", () getSettings: () => ({}), getMaximizedCellId: () => currentMaximizedId, flushChartSnapshots: () => Promise.resolve(), + readRefreshState: () => new Map(), } return { live, applied } } @@ -551,6 +552,7 @@ describe("createNotebookController — live runCell supersession", () => { getSettings: () => ({}), getMaximizedCellId: () => null, flushChartSnapshots: () => Promise.resolve(), + readRefreshState: () => new Map(), }) const cellWith = (result: CellResult): NotebookCell => ({ diff --git a/src/utils/notebooks/notebookController/index.ts b/src/utils/notebooks/notebookController/index.ts index 29e5228f9..c902fa433 100644 --- a/src/utils/notebooks/notebookController/index.ts +++ b/src/utils/notebooks/notebookController/index.ts @@ -85,6 +85,7 @@ export const withBoundNotebookReadOnly = async ( // === Consumer API — the AI-tool / read layers use only these ================ export { getController } from "./notebookControllerUtils" export type { + CellRefreshView, NotebookController, RunCellSummary, ApplyNotebookStateRequest, diff --git a/src/utils/notebooks/notebookController/notebookController.ts b/src/utils/notebooks/notebookController/notebookController.ts index 927649bd1..8bcae8f1a 100644 --- a/src/utils/notebooks/notebookController/notebookController.ts +++ b/src/utils/notebooks/notebookController/notebookController.ts @@ -34,6 +34,7 @@ import { runHeadlessCell, type DexieControllerDeps, } from "../notebookHeadlessRun" +import type { RunCellGate } from "../../tools/permissions" import type { NotebookTransitionResult } from "./notebookTransitions" // The two NotebookController implementations, side by side. Both expose the same @@ -48,6 +49,10 @@ export type RunCellSummary = { results: string[] unverified?: boolean note?: string + // Barrier decisions for gated (agent) runs: a permission denial or an + // auto-run write skip. Nothing executed when either is set. + denied?: string + skipped?: string } // Runs a transition against the bound document and resolves its result. A @@ -57,6 +62,15 @@ export type NotebookMutate = ( transition: (parts: ViewParts) => NotebookTransitionResult, ) => Promise +// Live-only refresh state, read straight off the refresh engine. Absent for +// unmounted notebooks — the tool docs say so, so an agent never reads absence +// as "not refreshing" or "not blocked". +export type CellRefreshView = { + refreshing: boolean + lastRefreshError?: string + autoRefreshBlocked?: "contains_write" +} + export type NotebookController = { bufferId: number // Route discriminant — a registry identity check would misclassify a live @@ -64,10 +78,12 @@ export type NotebookController = { kind: "live" | "dexie" mutate: NotebookMutate readView: () => Promise + readRefreshState?: () => ReadonlyMap runCell: ( cellId: string, signal?: AbortSignal, sql?: string, + gate?: RunCellGate, ) => Promise flushChartSnapshots?: () => Promise } @@ -76,11 +92,13 @@ export type NotebookController = { // `applyTransition` runs a transition against React state (cancelling any run of // a deleted cell via its cleanup list); the reads are synchronous ref snapshots. export type NotebookControllerActions = { + readRefreshState: () => ReadonlyMap runCell: ( cellId: string, sql?: string, signal?: AbortSignal, expectFullValue?: boolean, + gate?: RunCellGate, ) => Promise applyTransition: ( run: (parts: ViewParts) => NotebookTransitionResult, @@ -141,9 +159,10 @@ export const createNotebookController = ( maximizedCellId: liveActionsRef.current.getMaximizedCellId() ?? undefined, }), + readRefreshState: () => liveActionsRef.current.readRefreshState(), // runCell is not a transition, so it does not inherit requireCellIn — guard // it here, matching the passive route's requireCellIn in runHeadlessCell. - runCell: async (cellId, signal, sql) => { + runCell: async (cellId, signal, sql, gate) => { const cellBefore = requireCellIn( liveActionsRef.current.getCellsSnapshot(), cellId, @@ -158,8 +177,24 @@ export const createNotebookController = ( } } + const outcome = await liveActionsRef.current.runCell( + cellId, + sql, + signal, + true, + gate, + ) + if (outcome.denied !== undefined || outcome.skipped !== undefined) { + return { + ...summarizeCellResults(undefined), + ...(outcome.denied !== undefined ? { denied: outcome.denied } : {}), + ...(outcome.skipped !== undefined + ? { skipped: outcome.skipped } + : {}), + } + } const { superseded, cellChanged, notStarted, resultCleared, result } = - await liveActionsRef.current.runCell(cellId, sql, signal, true) + outcome if (superseded || cellChanged || resultCleared) { return { @@ -277,7 +312,7 @@ export const createDexieNotebookController = ( mutate, readView: () => enqueueBufferTask(bufferId, () => readNotebookView(bufferId)), - runCell: (cellId, signal, sql) => - runHeadlessCell(bufferId, deps, cellId, signal, sql), + runCell: (cellId, signal, sql, gate) => + runHeadlessCell(bufferId, deps, cellId, signal, sql, gate), } } diff --git a/src/utils/notebooks/notebookController/notebookTransitions.test.ts b/src/utils/notebooks/notebookController/notebookTransitions.test.ts index f170d9a9a..1262b0482 100644 --- a/src/utils/notebooks/notebookController/notebookTransitions.test.ts +++ b/src/utils/notebooks/notebookController/notebookTransitions.test.ts @@ -51,8 +51,8 @@ describe("applyNotebookStateTransition", () => { expect(out.result.applied.deleted).toEqual(["b", "c"]) }) - it("flags a value-changed run cell in deleteSnapshots, separate from cleanup", () => { - // Given a run cell with persisted run history and an untouched sibling + it("keeps a released cell's snapshot on a value change — hydration reconciles it", () => { + // Given a released run cell (history only, result on disk) and a sibling const parts = partsOf([ cell("a", "SELECT 1", { lastRunStatus: "success" }), cell("b", "SELECT 2"), @@ -64,12 +64,68 @@ describe("applyNotebookStateTransition", () => { { id: "b", preserveValue: true }, ], }) - // Then the surviving cell's stale snapshot travels in deleteSnapshots — - // not cleanup, which would also drop its layout and cancel its run - expect(out.deleteSnapshots?.cellIds).toEqual(["a"]) + // Then no snapshot deletion is requested — hydration reconciles the + // persisted results by statement content on the next load + expect(out.deleteSnapshots).toBeUndefined() expect(out.cleanup?.cellIds).toEqual([]) }) + it("flags a live result that loses every statement in deleteSnapshots", () => { + // Given a mounted run cell whose in-memory result matches its SQL + const parts = partsOf([ + cell("a", "SELECT 1", { + result: { + results: [ + { + type: "dql", + query: "SELECT 1", + columns: [{ name: "x", type: "INT" }], + dataset: [[1]], + count: 1, + }, + ], + activeResultIndex: 0, + timestamp: 0, + }, + }), + ]) + // When an apply rewrites the SQL so no statement survives + const out = applyNotebookStateTransition(parts, { + cells: [{ id: "a", value: "SELECT 99" }], + }) + // Then the frame collapses and the snapshot deletion is requested, so + // disk agrees with the collapsed cell on every later reload + expect(out.parts.cells[0].result).toBeNull() + expect(out.deleteSnapshots?.cellIds).toEqual(["a"]) + }) + + it("carries surviving statements' results through an apply rewrite", () => { + // Given a mounted two-statement cell with both results in memory + const dql = (query: string) => ({ + type: "dql" as const, + query, + columns: [{ name: "x", type: "INT" }], + dataset: [[1]], + count: 1, + }) + const parts = partsOf([ + cell("a", "SELECT 1; SELECT 2", { + result: { + results: [dql("SELECT 1"), dql("SELECT 2")], + activeResultIndex: 0, + timestamp: 0, + }, + }), + ]) + // When an apply edits only the second statement + const out = applyNotebookStateTransition(parts, { + cells: [{ id: "a", value: "SELECT 1; SELECT 99" }], + }) + // Then the unchanged statement keeps its result and nothing is deleted + expect(out.parts.cells[0].result?.results).toEqual([dql("SELECT 1")]) + expect(out.deleteSnapshots).toBeUndefined() + }) + it("omits deleteSnapshots when no run cell's value changed", () => { // Given a run cell whose value the apply preserves const parts = partsOf([cell("a", "SELECT 1", { lastRunStatus: "success" })]) @@ -311,6 +367,69 @@ describe("transition validation guards", () => { expect(out.parts.cells[0].topHeight).toBeUndefined() }) + const dqlOf = (query: string) => ({ + type: "dql" as const, + query, + columns: [{ name: "x", type: "INT" }], + dataset: [[1]], + count: 1, + }) + + it("updateCell carries surviving statements' results across a value edit", () => { + // Given a mounted two-statement cell with both results in memory + const ran = cell("a", "SELECT 1; SELECT 2", { + result: { + results: [dqlOf("SELECT 1"), dqlOf("SELECT 2")], + activeResultIndex: 0, + timestamp: 0, + }, + }) + + // When update_cell edits only the second statement + const out = updateCellTransition(partsOf([ran]), BUFFER_ID, "a", { + value: "SELECT 1; SELECT 99", + }) + + // Then the unchanged statement keeps its result, nothing is deleted + expect(out.parts.cells[0].result?.results).toEqual([dqlOf("SELECT 1")]) + expect(out.deleteSnapshots).toBeUndefined() + }) + + it("updateCell collapses the frame and flags the snapshot when nothing survives", () => { + // Given a mounted cell whose only statement is rewritten + const ran = cell("a", "SELECT 1", { + result: { + results: [dqlOf("SELECT 1")], + activeResultIndex: 0, + timestamp: 0, + }, + }) + + // When update_cell replaces the SQL wholesale + const out = updateCellTransition(partsOf([ran]), BUFFER_ID, "a", { + value: "SELECT 99", + }) + + // Then the frame collapses, history carries, and the snapshot is flagged + expect(out.parts.cells[0].result).toBeNull() + expect(out.parts.cells[0].lastRunStatus).toBe("success") + expect(out.deleteSnapshots?.cellIds).toEqual(["a"]) + }) + + it("updateCell keeps a released cell's snapshot on a value edit", () => { + // Given a released cell: run history only, results on disk + const released = cell("a", "SELECT 1", { lastRunStatus: "success" }) + + // When update_cell edits the SQL + const out = updateCellTransition(partsOf([released]), BUFFER_ID, "a", { + value: "SELECT 99", + }) + + // Then no snapshot deletion is requested — hydration reconciles it + expect(out.deleteSnapshots).toBeUndefined() + expect(out.parts.cells[0].lastRunStatus).toBe("success") + }) + it("deleteCell throws unknown_cell for a missing id", () => { expect( codeOf(() => diff --git a/src/utils/notebooks/notebookController/notebookTransitions.ts b/src/utils/notebooks/notebookController/notebookTransitions.ts index 7061dd5ba..e38ba2152 100644 --- a/src/utils/notebooks/notebookController/notebookTransitions.ts +++ b/src/utils/notebooks/notebookController/notebookTransitions.ts @@ -1,4 +1,5 @@ import { + chartAutoRefreshStamp, MAX_NOTEBOOK_CELLS, type AutoRefresh, type CellMode, @@ -12,6 +13,8 @@ import type { ApplyNotebookStateRequest } from "./notebookController" import type { ChartConfig } from "../../../scenes/Editor/Notebook/CellChart/chartTypes" import { buildAppliedNotebookState, + carriedRunError, + carriedRunStatus, cellHeightPatchForRows, cellModeChangePatch, clearCellAutoRefresh, @@ -20,6 +23,7 @@ import { isExpectingResult, mergeCellChartConfig, nextGridSeedPosition, + reconcileCellResultForValue, NOTEBOOK_GRID_MARGIN_Y, NOTEBOOK_GRID_ROW_HEIGHT, removeCell, @@ -120,14 +124,27 @@ export const updateCellTransition = ( if (!cell.topResized && updates.topHeight === undefined) { const estimated = topHeightForSql(updates.value) if (cell.topHeight == null || estimated !== topHeightForSql(cell.value)) { - patch = { ...updates, topHeight: estimated } + patch = { ...patch, topHeight: estimated } + } + } + if (updates.value !== cell.value && cell.result != null) { + const reconciled = reconcileCellResultForValue(cell.result, updates.value) + patch = { ...patch, result: reconciled } + if (reconciled === null) { + patch = { + ...patch, + lastRunStatus: carriedRunStatus(cell), + lastRunError: carriedRunError(cell), + } } } } + const resultDropped = patch.result === null && cell.result != null return { parts: { ...parts, cells: patchCellIn(parts.cells, cellId, patch) }, result: undefined, touchedCellId: cellId, + ...(resultDropped ? { deleteSnapshots: { cellIds: [cellId] } } : {}), } } @@ -229,9 +246,13 @@ export const setLayoutModeTransition = ( export const setNotebookAutoRefreshTransition = ( parts: ViewParts, value: AutoRefresh, + resetCellOverrides: boolean, ): NotebookTransitionResult => ({ parts: { ...parts, + cells: resetCellOverrides + ? parts.cells.map(clearCellAutoRefresh) + : parts.cells, settings: { ...parts.settings, autoRefreshDefault: value }, }, result: undefined, @@ -301,6 +322,12 @@ export const setCellModeTransition = ( cells: patchCellIn(parts.cells, cellId, { mode, ...cellModeChangePatch(cell, mode), + ...(entersDraw + ? chartAutoRefreshStamp( + { mode, autoRefresh: cell.autoRefresh }, + parts.settings.autoRefreshDefault, + ) + : {}), }), }, result: undefined, diff --git a/src/utils/notebooks/notebookDexieController.test.ts b/src/utils/notebooks/notebookDexieController.test.ts index b0bcd30d0..9f879628f 100644 --- a/src/utils/notebooks/notebookDexieController.test.ts +++ b/src/utils/notebooks/notebookDexieController.test.ts @@ -1,5 +1,6 @@ import "../../test/stubBrowserGlobals" import { beforeEach, describe, expect, it, vi } from "vitest" +import { clearStatementClassCache } from "../tools/permissions" import { __resetNotebookDexieControllerForTests, @@ -92,7 +93,7 @@ type PendingQuery = { resolve: (result: unknown) => void } -const makeQuest = () => { +const makeQuest = (opts: { validate?: (sql: string) => unknown } = {}) => { const pending: PendingQuery[] = [] const quest = { queryRaw: (sql: string) => { @@ -103,6 +104,9 @@ const makeQuest = () => { pending.push({ sql, resolve }) return { promise, queryId: `q-${pending.length}` } }, + ...(opts.validate + ? { validateQuery: (sql: string) => Promise.resolve(opts.validate!(sql)) } + : {}), abort: vi.fn(), } as unknown as Client const respondNext = (result: unknown) => { @@ -176,6 +180,7 @@ beforeEach(async () => { __resetNotebookBufferQueuesForTests() __resetNotebookDexieControllerForTests() __resetAgentActivityForTests() + clearStatementClassCache() await db.buffers.clear() await db.notebook_results.clear() }) @@ -577,7 +582,9 @@ describe("createDexieNotebookController — runCell", () => { expect(snapshot?.results?.[0]).toMatchObject({ type: "dql" }) }) - it("a multi-statement script stops on error and cancels the remainder", async () => { + it("falls back to a sequential stop-on-error script when classification is unavailable", async () => { + // The mock quest has no validateQuery — an unknown class never selects + // the parallel strategy. await seedNotebook({ cells: [cell("a", "SELECT 1; SELECT 2; SELECT 3")] }) const { quest, respondNext } = makeQuest() const controller = makeController({}, quest) @@ -589,6 +596,123 @@ describe("createDexieNotebookController — runCell", () => { expect(summary.results).toEqual(["success", "ERROR: boom", "cancelled"]) }) + const dqlValidation = { + query: "q", + columns: [{ name: "x", type: "INT" }], + timestamp: 0, + } + + it("runs a non-write multi-statement cell in parallel — one failure skips nothing", async () => { + // Given a classified all-DQL script + await seedNotebook({ cells: [cell("a", "SELECT 1; SELECT 2; SELECT 3")] }) + const { quest, pending, respondNext } = makeQuest({ + validate: () => dqlValidation, + }) + const controller = makeController({}, quest) + + // When it runs, every statement launches together + const run = controller.runCell("a") + await vi.waitFor(() => { + if (pending.length < 3) throw new Error("not all launched") + }) + + // And the middle one fails while its siblings succeed + respondNext(dqlResult) + respondNext(errorResult) + respondNext(dqlResult) + const summary = await run + + // Then the failure skips nothing — no cancelled remainder + expect(summary.success).toBe(false) + expect(summary.results).toEqual(["success", "ERROR: boom", "success"]) + }) + + it("skips an invalid statement with its validation error and runs its siblings", async () => { + // Given a script whose second statement fails validation + await seedNotebook({ cells: [cell("a", "SELECT 1; SELECT bad_col")] }) + const { quest, pending, respondNext } = makeQuest({ + validate: (sql) => + sql.includes("bad_col") + ? { query: sql, position: 0, error: "column not found" } + : dqlValidation, + }) + const controller = makeController({}, quest) + + // When it runs + const run = controller.runCell("a") + await vi.waitFor(() => { + if (pending.length < 1) throw new Error("sibling not launched") + }) + respondNext(dqlResult) + const summary = await run + + // Then the invalid statement never executed; its slot carries the error + expect(summary.results).toEqual(["success", "ERROR: column not found"]) + expect(pending).toHaveLength(0) + }) + + it("autoRun gate skips a write cell at the barrier", async () => { + // Given an auto-run of a DML cell + await seedNotebook({ cells: [cell("a", "INSERT INTO t VALUES (1)")] }) + const { quest, pending } = makeQuest({ + validate: () => ({ queryType: "INSERT" }), + }) + const controller = makeController({}, quest) + + // When the gated run reaches the barrier + const summary = await controller.runCell("a", undefined, undefined, { + kind: "autoRun", + }) + + // Then it skips without executing anything + expect(summary.skipped).toMatch(/AUTO_RUN_SKIPPED/) + expect(pending).toHaveLength(0) + }) + + it("explicit gate denies a write without the write permission at the barrier", async () => { + await seedNotebook({ cells: [cell("a", "INSERT INTO t VALUES (1)")] }) + const { quest, pending } = makeQuest({ + validate: () => ({ queryType: "INSERT" }), + }) + const controller = makeController({}, quest) + + const summary = await controller.runCell( + "a", + undefined, + "INSERT INTO t VALUES (1)", + { + kind: "explicit", + permissions: { grantSchemaAccess: true, read: true, write: false }, + }, + ) + + expect(summary.denied).toMatch(/'write' permission/) + expect(pending).toHaveLength(0) + }) + + it("explicit gate without write fails closed when classification is unreachable", async () => { + await seedNotebook({ cells: [cell("a", "SELECT 42 FROM never_cached")] }) + const { quest, pending } = makeQuest({ + validate: () => { + throw new Error("network down") + }, + }) + const controller = makeController({}, quest) + + const summary = await controller.runCell( + "a", + undefined, + "SELECT 42 FROM never_cached", + { + kind: "explicit", + permissions: { grantSchemaAccess: true, read: true, write: false }, + }, + ) + + expect(summary.denied).toMatch(/could not classify/) + expect(pending).toHaveLength(0) + }) + it("records a NOTICE result as a DQL with the notice attached", async () => { // Given a background run whose statement returns a notice + result set await seedNotebook({ cells: [cell("a", "ALTER TABLE t CONVERT")] }) diff --git a/src/utils/notebooks/notebookDexieView.ts b/src/utils/notebooks/notebookDexieView.ts index a65d2c935..9384d062d 100644 --- a/src/utils/notebooks/notebookDexieView.ts +++ b/src/utils/notebooks/notebookDexieView.ts @@ -6,7 +6,7 @@ import { dropLegacyChartConfigs, exceedsCellLineLimit, MAX_CELL_LINES, - migrateLegacyAutoRefresh, + migrateImplicitChartAutoRefresh, migrateLegacyCellNames, } from "../../store/notebook" import type { @@ -28,7 +28,9 @@ type NotebookBufferMeta = | { kind: "not_a_notebook" } export const migratePersistedNotebookView = (view: NotebookViewState) => - migrateLegacyAutoRefresh(dropLegacyChartConfigs(migrateLegacyCellNames(view))) + migrateImplicitChartAutoRefresh( + dropLegacyChartConfigs(migrateLegacyCellNames(view)), + ) export const readNotebookBufferMeta = async ( bufferId: number, diff --git a/src/utils/notebooks/notebookHeadlessRun.ts b/src/utils/notebooks/notebookHeadlessRun.ts index e0d7eb634..7ea556457 100644 --- a/src/utils/notebooks/notebookHeadlessRun.ts +++ b/src/utils/notebooks/notebookHeadlessRun.ts @@ -11,8 +11,16 @@ import type { RunCellSummary } from "./notebookController" import { NotebookToolError } from "./notebookToolError" import { enqueueBufferTask } from "./notebookBufferQueue" import { emitAgentEdit } from "./agentActivity" -import { executeSingleRaw } from "../executeSingleRaw" +import { executeSingleRaw, type QueryExecResult } from "../executeSingleRaw" import { getQueriesFromText } from "../../scenes/Editor/Monaco/utils" +import { createValidateWithGlobals } from "../../scenes/Editor/Notebook/declareUtils" +import { + hasWriteStatement, + resolveRunBarrier, + type ClassifiedStatement, + type RunCellGate, +} from "../tools/permissions" +import { statementRequestLimiter } from "../questdb/requestLimiter" import { buildInitialScriptResults, CELL_CHANGED_BEFORE_RUN_NOTE, @@ -218,12 +226,104 @@ const executeCellQueries = async (args: { } } +// Parallel execution for refreshable (non-write) cells — live-path parity: +// every DQL statement launches at once through the shared limiter, an invalid +// statement is skipped with its validation error as the slot result, and one +// failure skips nothing. +const executeCellQueriesParallel = async (args: { + queries: string[] + classified: ClassifiedStatement[] + variables: NotebookVariable[] | undefined + quest: Client + signal?: AbortSignal + supersedeSignal: AbortSignal +}): Promise => { + const { queries, classified, variables, quest, signal, supersedeSignal } = + args + const runAbort = new AbortController() + let aborted = false + const onAbort = () => { + aborted = true + runAbort.abort() + } + const abortSignals = signal ? [signal, supersedeSignal] : [supersedeSignal] + for (const abortSignal of abortSignals) { + if (abortSignal.aborted) onAbort() + else abortSignal.addEventListener("abort", onAbort, { once: true }) + } + + const startTime = Date.now() + const results: SingleQueryResult[] = buildInitialScriptResults(queries) + let successCount = 0 + let failedCount = 0 + + try { + await Promise.all( + queries.map(async (query, index) => { + const stmt = classified[index] + if (stmt?.klass === "ERROR") { + failedCount++ + results[index] = { + type: "error", + query, + error: stmt.error ?? "Invalid statement", + } + return + } + if (aborted) { + results[index] = { type: "cancelled", query, reason: "user" } + return + } + let exec: QueryExecResult + try { + exec = await statementRequestLimiter( + () => + executeSingleRaw( + quest, + query, + variables, + runAbort.signal, + NOTEBOOK_ROW_CAP, + ), + runAbort.signal, + ) + } catch { + results[index] = { type: "cancelled", query, reason: "user" } + return + } + results[index] = singleResultFromExec(exec, query) + if (exec.type === "error") failedCount++ + else successCount++ + }), + ) + + const result: CellResult = { + results, + activeResultIndex: 0, + timestamp: startTime, + } + if (queries.length > 1) { + result.script = { + successCount, + failedCount, + durationMs: Date.now() - startTime, + } + } + return result + } finally { + for (const abortSignal of abortSignals) { + abortSignal.removeEventListener("abort", onAbort) + } + } +} + export const runHeadlessCell = async ( bufferId: number, deps: DexieControllerDeps, cellId: string, signal?: AbortSignal, sql?: string, + gate?: RunCellGate, ): Promise => { const prep = await enqueueBufferTask(bufferId, async () => { const view = await readNotebookView(bufferId) @@ -255,20 +355,48 @@ export const runHeadlessCell = async ( ) } + // The runner's barrier classification is the single decision for permission + // enforcement, auto-run eligibility, and strategy — dispatch never + // classifies separately (live-path parity). + const validate = createValidateWithGlobals(quest, () => prep.variables) + const barrier = await resolveRunBarrier( + queryText, + queries.length, + gate, + (stmt) => statementRequestLimiter(() => validate(stmt, signal), signal), + ) + if (barrier.action === "denied") { + return { ...emptySummary(), denied: barrier.reason } + } + if (barrier.action === "skipped") { + return { ...emptySummary(), skipped: barrier.reason } + } + const classified = barrier.classified + const run = beginHeadlessRun(bufferId, cellId) try { // The queue is NOT held during execution, so runs on other cells of the // same notebook proceed in parallel; the commit re-reads and patches only // this cell. - const ranResult = await executeCellQueries({ - queries, - queryText, - variables: prep.variables, - quest, - signal, - supersedeSignal: run.signal, - }) + const ranResult = + classified && !hasWriteStatement(classified) + ? await executeCellQueriesParallel({ + queries, + classified, + variables: prep.variables, + quest, + signal, + supersedeSignal: run.signal, + }) + : await executeCellQueries({ + queries, + queryText, + variables: prep.variables, + quest, + signal, + supersedeSignal: run.signal, + }) const outcome = await enqueueBufferTask( bufferId, diff --git a/src/utils/questdb/requestLimiter.test.ts b/src/utils/questdb/requestLimiter.test.ts new file mode 100644 index 000000000..5f80ec8a6 --- /dev/null +++ b/src/utils/questdb/requestLimiter.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi } from "vitest" +import { createRequestLimiter } from "./requestLimiter" + +const deferred = () => { + let resolve!: () => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +describe("createRequestLimiter", () => { + it("never exceeds the active cap across queued tasks", async () => { + // Given a limiter with a cap of 2 and 5 competing tasks + const limit = createRequestLimiter(2) + let active = 0 + let maxActive = 0 + const tasks = Array.from({ length: 5 }, (_, i) => + limit(async () => { + active++ + maxActive = Math.max(maxActive, active) + await Promise.resolve() + active-- + return i + }), + ) + + // When all tasks complete + const results = await Promise.all(tasks) + + // Then concurrency saturated the cap without exceeding it + expect(maxActive).toBe(2) + expect(results).toEqual([0, 1, 2, 3, 4]) + }) + + it("a queued task aborted before its permit never executes", async () => { + // Given a saturated limiter with a second task waiting + const limit = createRequestLimiter(1) + const gate = deferred() + const first = limit(() => gate.promise) + const queuedTask = vi.fn(() => Promise.resolve("never")) + const controller = new AbortController() + const queued = limit(queuedTask, controller.signal) + + // When the waiting caller aborts + controller.abort() + + // Then it rejects without executing, and the permit still recycles + await expect(queued).rejects.toMatchObject({ name: "AbortError" }) + expect(queuedTask).not.toHaveBeenCalled() + gate.resolve() + await first + await expect(limit(() => Promise.resolve("ok"))).resolves.toBe("ok") + }) + + it("an already-aborted signal rejects before taking a permit", async () => { + // Given a free limiter and an aborted caller + const limit = createRequestLimiter(1) + const controller = new AbortController() + controller.abort() + const task = vi.fn(() => Promise.resolve("never")) + + // When the aborted caller submits a task + // Then it rejects and the task never runs + await expect(limit(task, controller.signal)).rejects.toMatchObject({ + name: "AbortError", + }) + expect(task).not.toHaveBeenCalled() + }) + + it("an abort between the permit grant and the task start prevents execution", async () => { + // Given a caller whose permit is granted synchronously + const limit = createRequestLimiter(1) + const controller = new AbortController() + const task = vi.fn(() => Promise.resolve("never")) + const pending = limit(task, controller.signal) + + // When it aborts before the task starts + controller.abort() + + // Then the task never runs and the permit is released + await expect(pending).rejects.toMatchObject({ name: "AbortError" }) + expect(task).not.toHaveBeenCalled() + await expect(limit(() => Promise.resolve("ok"))).resolves.toBe("ok") + }) +}) diff --git a/src/utils/questdb/requestLimiter.ts b/src/utils/questdb/requestLimiter.ts new file mode 100644 index 000000000..602b9e27c --- /dev/null +++ b/src/utils/questdb/requestLimiter.ts @@ -0,0 +1,61 @@ +export const MAX_ACTIVE_STATEMENT_REQUESTS = 8 + +export type RequestLimiter = ( + task: () => Promise, + signal?: AbortSignal, +) => Promise + +const abortReason = (signal: AbortSignal): unknown => + signal.reason ?? new DOMException("Aborted", "AbortError") + +export const createRequestLimiter = (maxActive: number): RequestLimiter => { + let active = 0 + const waiting: Array<() => void> = [] + + const acquire = (signal?: AbortSignal) => + new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(abortReason(signal)) + return + } + if (active < maxActive) { + active++ + resolve() + return + } + const start = () => { + signal?.removeEventListener("abort", onAbort) + active++ + resolve() + } + const onAbort = () => { + const index = waiting.indexOf(start) + if (index !== -1) waiting.splice(index, 1) + reject(abortReason(signal as AbortSignal)) + } + waiting.push(start) + signal?.addEventListener("abort", onAbort, { once: true }) + }) + + const release = () => { + active-- + waiting.shift()?.() + } + + return async ( + task: () => Promise, + signal?: AbortSignal, + ): Promise => { + await acquire(signal) + try { + if (signal?.aborted) throw abortReason(signal) + return await task() + } finally { + release() + } + } +} + +export const statementRequestLimiter = createRequestLimiter( + MAX_ACTIVE_STATEMENT_REQUESTS, +) diff --git a/src/utils/tools/applyNotebookState.ts b/src/utils/tools/applyNotebookState.ts index 4f4ddddcf..7a27ba26b 100644 --- a/src/utils/tools/applyNotebookState.ts +++ b/src/utils/tools/applyNotebookState.ts @@ -12,7 +12,6 @@ import { import type { CellMode, CellType, NotebookVariable } from "../../store/notebook" import type { ChartConfig } from "../../scenes/Editor/Notebook/CellChart/chartTypes" import { - classifyAndCheckSqlForAutoRun, denyReasonUnresolvedSql, requireAllDQL, type PermissionDecision, @@ -141,24 +140,9 @@ const runAppliedCells = async ( const settled = await Promise.all( resolved.map(async (r): Promise => { if (!r.runnable) return null - if (perms && validateSql) { - const decision = await classifyAndCheckSqlForAutoRun( - r.value, - validateSql, - ) - if (decision.action === "deny") { - return { cellId: r.cellId, success: false, error: decision.reason } - } - if (decision.action === "skip") { - return { - cellId: r.cellId, - success: true, - skipped: true, - note: decision.reason, - } - } - } try { + // The runner's barrier classification decides auto-run eligibility — + // writes are skipped at launch, never pre-checked. const result = await withBoundNotebook( bufferId, (ctrl) => @@ -166,9 +150,21 @@ const runAppliedCells = async ( r.cellId, signal, perms && validateSql ? r.value : undefined, + perms && validateSql ? { kind: "autoRun" } : undefined, ), signal, ) + if (result.denied !== undefined) { + return { cellId: r.cellId, success: false, error: result.denied } + } + if (result.skipped !== undefined) { + return { + cellId: r.cellId, + success: true, + skipped: true, + note: result.skipped, + } + } return { cellId: r.cellId, success: result.success, diff --git a/src/utils/tools/dispatch.ts b/src/utils/tools/dispatch.ts index 1e54d136f..d6be52076 100644 --- a/src/utils/tools/dispatch.ts +++ b/src/utils/tools/dispatch.ts @@ -19,13 +19,12 @@ import { } from "../../store/buffers" import type { ChartConfig } from "../../scenes/Editor/Notebook/CellChart/chartTypes" import { - classifyAndCheckSqlForAutoRun, - classifyAndCheckSqlForExecution, classifyAndCheckSqlForRunQuery, denyReasonUnresolvedSql, requireAllDQL, runPermissionGate, type Permissions, + type RunCellGate, } from "./permissions" import type { ValidateQueryResult } from "../questdb/types" import { @@ -139,10 +138,11 @@ const runCellBound = ( cellId: string, signal?: AbortSignal, sql?: string, + gate?: RunCellGate, ) => withBoundNotebook( bufferId, - (ctrl) => ctrl.runCell(cellId, signal, sql), + (ctrl) => ctrl.runCell(cellId, signal, sql, gate), signal, ) @@ -515,7 +515,10 @@ export const dispatchTool = async ( return routeNotebookTool(async () => ({ cells: await withBoundNotebookReadOnly( buffer_id, - (view) => Promise.resolve(summarizeCells(view.cells)), + (view, controller) => + Promise.resolve( + summarizeCells(view.cells, controller?.readRefreshState?.()), + ), signal, ), })) @@ -531,13 +534,14 @@ export const dispatchTool = async ( return routeNotebookTool(() => withBoundNotebookReadOnly( buffer_id, - (view) => + (view, controller) => Promise.resolve( serializeCell( view.cells, cell_id, buffer_id, get_full_content === true, + controller?.readRefreshState?.(), ), ), signal, @@ -589,34 +593,22 @@ export const dispatchTool = async ( : { cellId } } if (run) { - if (perms && validateSql) { - const decision = await classifyAndCheckSqlForAutoRun( - sql, - validateSql, - ) - if (decision.action === "deny") { - return { - cellId, - ran: false, - error: decision.reason, - } - } - if (decision.action === "skip") { - return { - cellId, - ran: false, - skipped: true, - note: decision.reason, - } - } - } setStatus(AIOperationStatus.RunningCell, { cellId }) + // The runner's barrier classification decides auto-run + // eligibility — writes are skipped at launch, never pre-checked. const r = await runCellBound( buffer_id, cellId, signal, perms && validateSql ? sql : undefined, + perms && validateSql ? { kind: "autoRun" } : undefined, ) + if (r.denied !== undefined) { + return { cellId, ran: false, error: r.denied } + } + if (r.skipped !== undefined) { + return { cellId, ran: false, skipped: true, note: r.skipped } + } return { cellId, ran: r.success, @@ -781,17 +773,30 @@ export const dispatchTool = async ( is_error: true, } } - const decision = await classifyAndCheckSqlForExecution( - value, - perms, - validateSql, - ) - if (!decision.granted) { - return { content: decision.reason, is_error: true } + // The runner's barrier classification enforces the permission — one + // classification per launch, shared with strategy selection. + let deniedReason: string | undefined + const routed = await routeNotebookTool(async () => { + const summary = await runCellBound( + buffer_id, + cell_id, + signal, + value, + { + kind: "explicit", + permissions: perms, + }, + ) + if (summary.denied !== undefined) { + deniedReason = summary.denied + return {} + } + return summary + }) + if (deniedReason !== undefined) { + return { content: deniedReason, is_error: true } } - return routeNotebookTool(() => - runCellBound(buffer_id, cell_id, signal, value), - ) + return routed } return routeNotebookTool(() => runCellBound(buffer_id, cell_id, signal)) } @@ -975,7 +980,22 @@ export const dispatchTool = async ( is_error: true, } } - setStatus(AIOperationStatus.ConfiguringChart, { cellId: cell_id }) + // Markdown cells hold prose and never run, so an interval on them is + // meaningless — mirrors run_cell's markdown refusal. + const autoRefreshCell = (await readCells(buffer_id, signal)).find( + (c) => c.id === cell_id, + ) + if (autoRefreshCell?.type === "markdown") { + return { + content: JSON.stringify({ + error_code: "validation", + message: + "VALIDATION_ERROR: markdown cells are never executed, so auto-refresh does not apply to them.", + }), + is_error: true, + } + } + setStatus(AIOperationStatus.ConfiguringAutoRefresh, { cellId: cell_id }) return routeNotebookTool(() => runTransition( buffer_id, @@ -990,8 +1010,12 @@ export const dispatchTool = async ( ) } case "set_notebook_autorefresh": { - const { buffer_id, value } = - (input as { buffer_id: number; value: unknown }) || {} + const { buffer_id, value, reset_cell_overrides } = + (input as { + buffer_id: number + value: unknown + reset_cell_overrides?: boolean + }) || {} if (!isAutoRefresh(value)) { return { content: JSON.stringify({ @@ -1001,11 +1025,16 @@ export const dispatchTool = async ( is_error: true, } } - setStatus(AIOperationStatus.ConfiguringChart) + setStatus(AIOperationStatus.ConfiguringAutoRefresh) return routeNotebookTool(() => runTransition( buffer_id, - (parts) => setNotebookAutoRefreshTransition(parts, value), + (parts) => + setNotebookAutoRefreshTransition( + parts, + value, + reset_cell_overrides === true, + ), signal, ), ) diff --git a/src/utils/tools/permissions.test.ts b/src/utils/tools/permissions.test.ts index b0e37c506..32526e53e 100644 --- a/src/utils/tools/permissions.test.ts +++ b/src/utils/tools/permissions.test.ts @@ -1,20 +1,30 @@ -import { describe, it, expect, vi } from "vitest" +import { describe, it, expect, vi, beforeEach } from "vitest" import { DEFAULT_DENIED, DEFAULT_GRANTED, + checkStatementsForAutoRun, + checkStatementsForExecution, + checkStatementsForRunQuery, checkToolPermission, classifyAndCheckSqlForAutoRun, classifyAndCheckSqlForExecution, classifyAndCheckSqlForRunQuery, classifyStatements, + clearStatementClassCache, normalizePermissions, requireAllDQL, + resolveRunBarrier, runPermissionGate, + type ClassifiedStatement, type Permissions, type ToolCategory, } from "./permissions" import type { ValidateQueryResult } from "../questdb/types" +beforeEach(() => { + clearStatementClassCache() +}) + // Three-scope state fixtures. Cascade: write ⇒ read ⇒ grantSchemaAccess. const ALL_OFF: Permissions = { grantSchemaAccess: false, @@ -200,10 +210,12 @@ describe("classifyStatements", () => { ]) }) - it("classifies syntax errors as ERROR", async () => { + it("classifies syntax errors as ERROR and retains the server message", async () => { const validate = vi.fn().mockResolvedValue(errorValidate) const out = await classifyStatements("BAD", validate) - expect(out).toEqual([{ sql: "BAD", klass: "ERROR" }]) + expect(out).toEqual([ + { sql: "BAD", klass: "ERROR", error: "syntax", errorPosition: 0 }, + ]) }) it("splits and classifies each statement of a multi-statement cell", async () => { @@ -235,6 +247,292 @@ describe("classifyStatements", () => { }) }) +describe("statement class cache", () => { + it("caches DQL and DDL_DML classes by statement text", async () => { + // Given a mixed cell classified once + const validate = vi.fn( + validatorFor({ + "SELECT 1": dqlValidate("SELECT 1"), + "INSERT INTO t VALUES (1)": dmlValidate, + }), + ) + await classifyStatements("SELECT 1; INSERT INTO t VALUES (1)", validate) + expect(validate).toHaveBeenCalledTimes(2) + + // When the same statements are classified again + const out = await classifyStatements( + "SELECT 1; INSERT INTO t VALUES (1)", + validate, + ) + + // Then no new validate requests are made and classes are preserved + expect(validate).toHaveBeenCalledTimes(2) + expect(out).toEqual([ + { sql: "SELECT 1", klass: "DQL" }, + { + sql: "INSERT INTO t VALUES (1)", + klass: "DDL_DML", + queryType: "INSERT", + }, + ]) + }) + + it("hits the cache across whitespace and trailing-semicolon variants", async () => { + // Given a statement classified once + const validate = vi.fn().mockResolvedValue(dqlValidate("SELECT 1")) + await classifyStatements("SELECT 1", validate) + + // When the same statement is classified with a trailing semicolon + const out = await classifyStatements(" SELECT 1; ", validate) + + // Then the cached class is reused + expect(validate).toHaveBeenCalledTimes(1) + expect(out[0].klass).toBe("DQL") + }) + + it("never caches ERROR — an invalid statement is revalidated every time", async () => { + // Given a statement that fails validation, then validates as DML + const validate = vi + .fn() + .mockResolvedValueOnce(errorValidate) + .mockResolvedValueOnce(dmlValidate) + await classifyStatements("BAD", validate) + + // When it is classified again after the schema changed + const out = await classifyStatements("BAD", validate) + + // Then the fresh class wins + expect(validate).toHaveBeenCalledTimes(2) + expect(out).toEqual([{ sql: "BAD", klass: "DDL_DML", queryType: "INSERT" }]) + }) + + it("a transport failure caches nothing", async () => { + // Given a validate call that fails at the transport level + const validate = vi.fn().mockRejectedValueOnce(new Error("network down")) + await expect(classifyStatements("SELECT 1", validate)).rejects.toThrow() + + // When classification is retried after connectivity returns + validate.mockResolvedValueOnce(dqlValidate("SELECT 1")) + const out = await classifyStatements("SELECT 1", validate) + + // Then the statement is validated again + expect(validate).toHaveBeenCalledTimes(2) + expect(out[0].klass).toBe("DQL") + }) +}) + +describe("checkStatements* (pre-classified barrier variants)", () => { + const dqlStmt: ClassifiedStatement = { sql: "SELECT 1", klass: "DQL" } + const writeStmt: ClassifiedStatement = { + sql: "INSERT INTO t VALUES (1)", + klass: "DDL_DML", + queryType: "INSERT", + } + const errorStmt: ClassifiedStatement = { + sql: "BAD", + klass: "ERROR", + error: "syntax", + } + + it("execution: write statement without write permission → denied", () => { + const decision = checkStatementsForExecution( + [dqlStmt, writeStmt], + READ_ONLY, + ) + if (decision.granted) throw new Error("expected deny") + expect(decision.reason).toMatch(/INSERT/) + }) + + it("execution: invalid statements never demote the cell — DQL + ERROR is granted", () => { + expect( + checkStatementsForExecution([dqlStmt, errorStmt], READ_ONLY), + ).toEqual({ granted: true }) + }) + + it("run_query: DQL without read → denied for read", () => { + const decision = checkStatementsForRunQuery([dqlStmt], SCHEMA_ONLY) + expect(decision.granted).toBe(false) + }) + + it("auto-run: write statement → skip; DQL + ERROR → run", () => { + expect(checkStatementsForAutoRun([dqlStmt, writeStmt]).action).toBe("skip") + expect(checkStatementsForAutoRun([dqlStmt, errorStmt])).toEqual({ + action: "run", + }) + }) +}) + +describe("resolveRunBarrier — the runner's pre-launch gate", () => { + it("skips classification for an ungated single statement", async () => { + // Given a plain UI run of one statement + const validate = vi.fn() + + // When the barrier resolves + const out = await resolveRunBarrier("SELECT 1", 1, undefined, validate) + + // Then no validate round trip happens and the run proceeds unclassified + expect(validate).not.toHaveBeenCalled() + expect(out).toEqual({ action: "proceed", classified: null }) + }) + + it("classifies an ungated multi-statement cell for strategy", async () => { + // Given an ungated script of two reads + const validate = vi.fn(validatorFor({})) + + // When the barrier resolves + const out = await resolveRunBarrier( + "SELECT 1; SELECT 2", + 2, + undefined, + validate, + ) + + // Then every statement is classified so the runner can pick parallel + expect(out.action).toBe("proceed") + if (out.action === "proceed") { + expect(out.classified?.map((s) => s.klass)).toEqual(["DQL", "DQL"]) + } + }) + + it("explicit gate: denies a write without the write permission", async () => { + // Given an agent-run cell containing an INSERT under read-only perms + const validate = vi.fn( + validatorFor({ "INSERT INTO t VALUES (1)": dmlValidate }), + ) + + // When the barrier resolves + const out = await resolveRunBarrier( + "INSERT INTO t VALUES (1)", + 1, + { kind: "explicit", permissions: READ_ONLY }, + validate, + ) + + // Then the run is denied before anything executes + expect(out.action).toBe("denied") + if (out.action === "denied") expect(out.reason).toMatch(/INSERT/) + }) + + it("explicit gate: grants a write with the write permission", async () => { + // Given the same INSERT with write granted + const validate = vi.fn( + validatorFor({ "INSERT INTO t VALUES (1)": dmlValidate }), + ) + + // When the barrier resolves + const out = await resolveRunBarrier( + "INSERT INTO t VALUES (1)", + 1, + { kind: "explicit", permissions: ALL_ON }, + validate, + ) + + // Then the run proceeds with the classification attached + expect(out.action).toBe("proceed") + if (out.action === "proceed") { + expect(out.classified?.[0].klass).toBe("DDL_DML") + } + }) + + it("autoRun gate: skips a write cell — agent flows never auto-run DDL/DML", async () => { + // Given an auto-run of a DML cell + const validate = vi.fn( + validatorFor({ "INSERT INTO t VALUES (1)": dmlValidate }), + ) + + // When the barrier resolves + const out = await resolveRunBarrier( + "INSERT INTO t VALUES (1)", + 1, + { kind: "autoRun" }, + validate, + ) + + // Then the cell is skipped, never executed + expect(out.action).toBe("skipped") + if (out.action === "skipped") expect(out.reason).toMatch(/AUTO_RUN_SKIPPED/) + }) + + it("autoRun gate: an invalid statement never demotes the cell", async () => { + // Given a read cell whose second statement fails validation + const validate = vi.fn(validatorFor({ BAD: errorValidate })) + + // When the barrier resolves + const out = await resolveRunBarrier( + "SELECT 1; BAD", + 2, + { kind: "autoRun" }, + validate, + ) + + // Then the run proceeds — the invalid slot carries its error instead + expect(out.action).toBe("proceed") + }) + + it("fails closed when classification is unreachable under autoRun", async () => { + // Given a validate transport that is down + const validate = vi.fn().mockRejectedValue(new Error("network down")) + + // When an auto-run reaches the barrier + const out = await resolveRunBarrier( + "SELECT 1", + 1, + { kind: "autoRun" }, + validate, + ) + + // Then the run is denied — an unclassifiable cell could hide a write + expect(out.action).toBe("denied") + if (out.action === "denied") + expect(out.reason).toMatch(/could not classify/) + }) + + it("fails closed when explicit-without-write cannot classify", async () => { + const validate = vi.fn().mockRejectedValue(new Error("network down")) + + const out = await resolveRunBarrier( + "SELECT 1", + 1, + { kind: "explicit", permissions: READ_ONLY }, + validate, + ) + + expect(out.action).toBe("denied") + }) + + it("falls open to the unclassified path when explicit-with-write cannot classify", async () => { + // Given write permission — the gate could not be used to smuggle a write + const validate = vi.fn().mockRejectedValue(new Error("network down")) + + // When the barrier resolves + const out = await resolveRunBarrier( + "SELECT 1", + 1, + { kind: "explicit", permissions: ALL_ON }, + validate, + ) + + // Then the run proceeds without a classification (sequential strategy) + expect(out).toEqual({ action: "proceed", classified: null }) + }) + + it("falls open when an ungated multi-statement cell cannot classify", async () => { + // Given a plain UI script run with validation unreachable + const validate = vi.fn().mockRejectedValue(new Error("network down")) + + // When the barrier resolves + const out = await resolveRunBarrier( + "SELECT 1; SELECT 2", + 2, + undefined, + validate, + ) + + // Then the user's run still proceeds on the sequential path + expect(out).toEqual({ action: "proceed", classified: null }) + }) +}) + describe("classifyAndCheckSqlForRunQuery", () => { it("denies empty SQL without calling validate", async () => { const validate = vi.fn() diff --git a/src/utils/tools/permissions.ts b/src/utils/tools/permissions.ts index 65cd5a803..7c50a2a21 100644 --- a/src/utils/tools/permissions.ts +++ b/src/utils/tools/permissions.ts @@ -2,7 +2,10 @@ import type { ValidateQueryResult, ValidateQuerySuccessResult, } from "../questdb/types" -import { getQueriesFromText } from "../../scenes/Editor/Monaco/utils" +import { + getQueriesFromText, + normalizeQueryText, +} from "../../scenes/Editor/Monaco/utils" export type Permissions = { grantSchemaAccess: boolean @@ -96,6 +99,31 @@ export type ClassifiedStatement = { sql: string klass: StatementClass queryType?: string + error?: string + errorPosition?: number +} + +type CachedStatementClass = + | { klass: "DQL" } + | { klass: "DDL_DML"; queryType?: string } + +// Successful classes are grammar-stable for a given text, so they cache +// safely. ERROR is schema-dependent (a missing table can appear later) and +// is revalidated on every classification. +const STATEMENT_CLASS_CACHE_MAX = 500 + +const statementClassCache = new Map() + +export const clearStatementClassCache = () => { + statementClassCache.clear() +} + +const cacheStatementClass = (key: string, value: CachedStatementClass) => { + if (statementClassCache.size >= STATEMENT_CLASS_CACHE_MAX) { + const oldest = statementClassCache.keys().next().value + if (oldest !== undefined) statementClassCache.delete(oldest) + } + statementClassCache.set(key, value) } export const classifyStatements = async ( @@ -106,15 +134,74 @@ export const classifyStatements = async ( if (statements.length === 0) return [] const results = await Promise.all( statements.map(async (stmt): Promise => { + const key = normalizeQueryText(stmt) + const cached = statementClassCache.get(key) + if (cached) return { sql: stmt, ...cached } const result = await validate(stmt) - if ("error" in result) return { sql: stmt, klass: "ERROR" } - if (isDqlResult(result)) return { sql: stmt, klass: "DQL" } + if ("error" in result) { + return { + sql: stmt, + klass: "ERROR", + error: result.error, + errorPosition: result.position, + } + } + if (isDqlResult(result)) { + cacheStatementClass(key, { klass: "DQL" }) + return { sql: stmt, klass: "DQL" } + } + cacheStatementClass(key, { + klass: "DDL_DML", + queryType: result.queryType, + }) return { sql: stmt, klass: "DDL_DML", queryType: result.queryType } }), ) return results } +export const hasWriteStatement = (stmts: ClassifiedStatement[]): boolean => + stmts.some((s) => s.klass === "DDL_DML") + +// Barrier-bound run gating: the runner's pre-launch classification is the +// single decision for permission enforcement, auto-run eligibility, and +// strategy — dispatch never classifies separately. +export type RunCellGate = + | { kind: "explicit"; permissions: Permissions } + | { kind: "autoRun" } + +export const checkStatementsForRunQuery = ( + stmts: ClassifiedStatement[], + perms: Permissions, +): PermissionDecision => { + const writeStmt = stmts.find((s) => s.klass === "DDL_DML") + if (writeStmt && !perms.write) { + return { + granted: false, + reason: denyReasonForWriteSql(writeStmt.queryType ?? "write"), + } + } + const hasDql = stmts.some((s) => s.klass === "DQL") + if (hasDql && !perms.read && !perms.write) { + return { granted: false, reason: denyReasonForReadSql() } + } + return { granted: true } +} + +export const checkStatementsForExecution = ( + stmts: ClassifiedStatement[], + perms: Permissions, +): PermissionDecision => { + const writeStmt = stmts.find((s) => s.klass === "DDL_DML") + if (writeStmt && !perms.write) { + return { + granted: false, + reason: denyReasonForWriteSql(writeStmt.queryType ?? "write"), + } + } + return { granted: true } +} + export const classifyAndCheckSqlForRunQuery = async ( sql: string, perms: Permissions, @@ -133,18 +220,7 @@ export const classifyAndCheckSqlForRunQuery = async ( reason: denyReasonFailClosedClassify("execution", message), } } - const writeStmt = stmts.find((s) => s.klass === "DDL_DML") - if (writeStmt && !perms.write) { - return { - granted: false, - reason: denyReasonForWriteSql(writeStmt.queryType ?? "write"), - } - } - const hasDql = stmts.some((s) => s.klass === "DQL") - if (hasDql && !perms.read && !perms.write) { - return { granted: false, reason: denyReasonForReadSql() } - } - return { granted: true } + return checkStatementsForRunQuery(stmts, perms) } export const classifyAndCheckSqlForExecution = async ( @@ -165,14 +241,7 @@ export const classifyAndCheckSqlForExecution = async ( reason: denyReasonFailClosedClassify("execution", message), } } - const writeStmt = stmts.find((s) => s.klass === "DDL_DML") - if (writeStmt && !perms.write) { - return { - granted: false, - reason: denyReasonForWriteSql(writeStmt.queryType ?? "write"), - } - } - return { granted: true } + return checkStatementsForExecution(stmts, perms) } export type AutoRunDecision = @@ -185,6 +254,68 @@ const skipReasonForWrite = (queryType: string): string => "so it was NOT executed — agent flows never auto-run DDL/DML. " + "Confirm with the user, then call run_cell explicitly." +export const checkStatementsForAutoRun = ( + stmts: ClassifiedStatement[], +): AutoRunDecision => { + const writeStmt = stmts.find((s) => s.klass === "DDL_DML") + if (!writeStmt) return { action: "run" } + return { + action: "skip", + reason: skipReasonForWrite(writeStmt.queryType ?? "write"), + } +} + +export type RunBarrierOutcome = + | { action: "proceed"; classified: ClassifiedStatement[] | null } + | { action: "denied"; reason: string } + | { action: "skipped"; reason: string } + +// The runner's pre-launch barrier, shared by the live and headless paths: one +// classification per launch decides permission enforcement, auto-run +// eligibility, and strategy. A plain single-statement run skips it — there is +// no strategy to pick and the server enforces validity itself. Classification +// failure fails closed exactly when the gate could not otherwise stop a +// write: autoRun always, explicit only without the write permission. +export const resolveRunBarrier = async ( + queryText: string, + statementCount: number, + gate: RunCellGate | undefined, + validate: (stmt: string) => Promise, +): Promise => { + if (gate === undefined && statementCount <= 1) { + return { action: "proceed", classified: null } + } + let classified: ClassifiedStatement[] + try { + classified = await classifyStatements(queryText, validate) + } catch (err) { + const message = err instanceof Error ? err.message : "validate failed" + const failClosed = + gate?.kind === "autoRun" || + (gate?.kind === "explicit" && !gate.permissions.write) + if (failClosed) { + return { + action: "denied", + reason: denyReasonFailClosedClassify("execution", message), + } + } + return { action: "proceed", classified: null } + } + if (gate?.kind === "explicit") { + const decision = checkStatementsForExecution(classified, gate.permissions) + if (!decision.granted) { + return { action: "denied", reason: decision.reason } + } + } + if (gate?.kind === "autoRun") { + const decision = checkStatementsForAutoRun(classified) + if (decision.action === "skip") { + return { action: "skipped", reason: decision.reason } + } + } + return { action: "proceed", classified } +} + export const classifyAndCheckSqlForAutoRun = async ( sql: string, validate: (sql: string) => Promise, @@ -199,12 +330,7 @@ export const classifyAndCheckSqlForAutoRun = async ( reason: denyReasonFailClosedClassify("execution", message), } } - const writeStmt = stmts.find((s) => s.klass === "DDL_DML") - if (!writeStmt) return { action: "run" } - return { - action: "skip", - reason: skipReasonForWrite(writeStmt.queryType ?? "write"), - } + return checkStatementsForAutoRun(stmts) } // Permission-independent: drawing a write query is semantically incoherent, From 8b78e35f833d441f2a0b8d2ddb3f0f4fdce7eb11 Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 11 Aug 2026 16:50:23 +0300 Subject: [PATCH 10/17] fix review findings on grid auto-refresh: render storm, live-region flood, refresh button focus - coalesce per-slot engine updates into one patch per settle and per launch - parse cell SQL only when text changes, skip for draw cells - return empty summary when a headless run aborts mid-validation - announce refresh status from the hidden live region only, keep steady-state polls silent - block refresh clicks via aria-disabled + guard so polls no longer evict keyboard focus Co-Authored-By: Claude Fable 5 --- .../Notebook/cellRefresh/cellRefreshEngine.ts | 80 ++++++++++++------- .../Notebook/cells/CellBottomContent.tsx | 8 +- .../Notebook/cells/CellRefreshButton.tsx | 5 +- .../Editor/Notebook/refreshSplitButton.tsx | 13 ++- .../result-table/StatusNotification.tsx | 20 +++-- src/utils/notebooks/notebookHeadlessRun.ts | 1 + 6 files changed, 87 insertions(+), 40 deletions(-) diff --git a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts index 64ede39ac..ceeaf0f06 100644 --- a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts +++ b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts @@ -1180,22 +1180,41 @@ export class CellRefreshEngine { this.updatePoll(entry) return } - this.setState(entry, { classifyBlock: null, classifiedKey: queriesKey }) const slotKeys = statementKeysFor(queries) - let hadFailure = false + const invalidSlots: Array<{ key: StatementKey; message: string }> = [] + const launchSlots: Array<{ key: StatementKey; index: number }> = [] + slotKeys.forEach((key, index) => { + if (entry.state.cancelledSlots.has(key)) return + const stmt = classified[index] + if (stmt?.klass === "ERROR") { + invalidSlots.push({ key, message: stmt.error ?? "Invalid statement" }) + } else { + launchSlots.push({ key, index }) + } + }) + const launchPatch: Partial = { + classifyBlock: null, + classifiedKey: queriesKey, + } + if (invalidSlots.length > 0) { + const slotErrors = new Map(entry.state.slotErrors) + invalidSlots.forEach(({ key, message }) => + slotErrors.set(key, message.trim()), + ) + launchPatch.slotErrors = slotErrors + } + if (launchSlots.length > 0) { + const slotFetching = new Set(entry.state.slotFetching) + launchSlots.forEach(({ key }) => slotFetching.add(key)) + launchPatch.slotFetching = slotFetching + } + this.setState(entry, launchPatch) + let hadFailure = invalidSlots.length > 0 await Promise.all( - slotKeys.map(async (key, index) => { + launchSlots.map(async ({ key, index }) => { if (round.signal.aborted) return - if (entry.state.cancelledSlots.has(key)) return - const stmt = classified[index] - if (stmt?.klass === "ERROR") { - hadFailure = true - this.setSlotError(entry, key, stmt.error ?? "Invalid statement") - return - } const slotAbort = new AbortController() entry.slotAborts.set(key, slotAbort) - this.setSlotFetching(entry, key, true) try { const exec = await this.limitRequest( () => @@ -1220,12 +1239,8 @@ export class CellRefreshEngine { const unchanged = previous !== undefined && resultsEquivalent([toExecResult(previous)], [exec]) - if (!unchanged) { - this.commitSlotResult(entry, key, exec) - this.stampSlotSwappedAt(entry, key) - } - this.stampSlotFetchedAt(entry, key) - this.clearSlotError(entry, key) + if (!unchanged) this.commitSlotResult(entry, key, exec) + this.settleSlotSuccess(entry, key, !unchanged) } } catch (e) { if (slotAbort.signal.aborted || round.signal.aborted) return @@ -1236,7 +1251,8 @@ export class CellRefreshEngine { // replacement round registered under the same key. if (entry.slotAborts.get(key) === slotAbort) { entry.slotAborts.delete(key) - this.setSlotFetching(entry, key, false) + if (entry.state.slotFetching.has(key)) + this.setSlotFetching(entry, key, false) } } }), @@ -1353,16 +1369,26 @@ export class CellRefreshEngine { this.setState(entry, { slotFetchedAt, slotSwappedAt }) } - private stampSlotFetchedAt(entry: Entry, key: StatementKey) { + // A settle is one patch — stamps, error clear and fetching flip land in a + // single notify, so a round of S slots costs S renders, not 5S. + private settleSlotSuccess(entry: Entry, key: StatementKey, swapped: boolean) { + const now = Date.now() const slotFetchedAt = new Map(entry.state.slotFetchedAt) - slotFetchedAt.set(key, Date.now()) - this.setState(entry, { slotFetchedAt }) - } - - private stampSlotSwappedAt(entry: Entry, key: StatementKey) { - const slotSwappedAt = new Map(entry.state.slotSwappedAt) - slotSwappedAt.set(key, Date.now()) - this.setState(entry, { slotSwappedAt }) + slotFetchedAt.set(key, now) + const slotFetching = new Set(entry.state.slotFetching) + slotFetching.delete(key) + const patch: Partial = { slotFetchedAt, slotFetching } + if (swapped) { + const slotSwappedAt = new Map(entry.state.slotSwappedAt) + slotSwappedAt.set(key, now) + patch.slotSwappedAt = slotSwappedAt + } + if (entry.state.slotErrors.has(key)) { + const slotErrors = new Map(entry.state.slotErrors) + slotErrors.delete(key) + patch.slotErrors = slotErrors + } + this.setState(entry, patch) } private setSlotFetching(entry: Entry, key: StatementKey, fetching: boolean) { diff --git a/src/scenes/Editor/Notebook/cells/CellBottomContent.tsx b/src/scenes/Editor/Notebook/cells/CellBottomContent.tsx index 973127366..f831dfdd3 100644 --- a/src/scenes/Editor/Notebook/cells/CellBottomContent.tsx +++ b/src/scenes/Editor/Notebook/cells/CellBottomContent.tsx @@ -49,11 +49,15 @@ export const CellBottomContent: React.FC = ({ // Tabs follow the editor's statement list; results attach to it by content. // A statement with no result renders the neutral "Not run" slot. A frame no // statement claims (selection run) falls back to the results' own tabs. + const statements = useMemo( + () => (cell.mode === "draw" ? [] : getQueriesFromText(cell.value)), + [cell.mode, cell.value], + ) const frame = useMemo( () => - deriveStatementFrame(getQueriesFromText(cell.value), cell.result) ?? + deriveStatementFrame(statements, cell.result) ?? derivePositionalFrame(cell.result), - [cell.value, cell.result], + [statements, cell.result], ) const slots = useMemo( () => (frame ? buildStatementSlotViews(frame, fetchState) : []), diff --git a/src/scenes/Editor/Notebook/cells/CellRefreshButton.tsx b/src/scenes/Editor/Notebook/cells/CellRefreshButton.tsx index ad8a0e55e..7347c64fc 100644 --- a/src/scenes/Editor/Notebook/cells/CellRefreshButton.tsx +++ b/src/scenes/Editor/Notebook/cells/CellRefreshButton.tsx @@ -55,6 +55,7 @@ export const CellRefreshButton: React.FC = ({ const handleRefresh = (e: React.MouseEvent) => { e.stopPropagation() + if (refreshing) return signalUserEdit(bufferId) if (isChart) void trackEvent(ConsoleEvent.NOTEBOOK_CELL_DRAW) eventBus.publish( @@ -84,7 +85,9 @@ export const CellRefreshButton: React.FC = ({ onClick={handleRefresh} aria-label="Refresh" aria-busy={refreshing} - disabled={refreshing} + // aria-disabled + click guard, not native disabled: a poll tick must + // not evict keyboard focus from the button mid-cycle. + aria-disabled={refreshing || undefined} > {refreshing ? : } diff --git a/src/scenes/Editor/Notebook/refreshSplitButton.tsx b/src/scenes/Editor/Notebook/refreshSplitButton.tsx index 08158665e..e72079e87 100644 --- a/src/scenes/Editor/Notebook/refreshSplitButton.tsx +++ b/src/scenes/Editor/Notebook/refreshSplitButton.tsx @@ -26,13 +26,22 @@ export const SplitSide = styled(Button)` height: 1.8rem; } - &&:hover:not(:disabled) { + &&:hover:not(:disabled):not([aria-disabled="true"]) { background: ${({ theme }) => `${theme.color.selection}80`}; color: ${({ theme }) => theme.color.foreground}; } - &:disabled { + &&:hover[aria-disabled="true"], + &&:active[aria-disabled="true"] { + background: transparent; + color: ${({ theme }) => theme.color.foreground}; + filter: none; + } + + &:disabled, + &[aria-disabled="true"] { opacity: 0.5; + cursor: default; } ` diff --git a/src/scenes/Editor/Notebook/result-table/StatusNotification.tsx b/src/scenes/Editor/Notebook/result-table/StatusNotification.tsx index 8017f4340..66791b73f 100644 --- a/src/scenes/Editor/Notebook/result-table/StatusNotification.tsx +++ b/src/scenes/Editor/Notebook/result-table/StatusNotification.tsx @@ -12,8 +12,11 @@ import { CancelButton, LiveRegion, NotificationContainer } from "./styles" import { trackEvent } from "../../../../modules/ConsoleEventTracker" import { ConsoleEvent } from "../../../../modules/ConsoleEventTracker/events" +// Announcements ride on TEXT CHANGES: a steady-state poll that reverifies the +// same rows keeps the message identical, so the screen reader stays quiet. +// Failures, recoveries and row-count changes alter the text and are read out. const liveRegionMessage = (slot: StatementSlotView): string => { - if (slot.refreshing) return "Refreshing query" + if (slot.refreshing && slot.result === null) return "Refreshing query" if (slot.result?.type === "running") return "Query running" if (slot.refreshError !== undefined) { return `Refresh failed: ${slot.refreshError}` @@ -204,13 +207,14 @@ export const StatusNotification: React.FC = ({ } return ( - - {liveRegionMessage(slot)} + + + {liveRegionMessage(slot)} + {body} ) diff --git a/src/utils/notebooks/notebookHeadlessRun.ts b/src/utils/notebooks/notebookHeadlessRun.ts index 7ea556457..6850d5dd3 100644 --- a/src/utils/notebooks/notebookHeadlessRun.ts +++ b/src/utils/notebooks/notebookHeadlessRun.ts @@ -365,6 +365,7 @@ export const runHeadlessCell = async ( gate, (stmt) => statementRequestLimiter(() => validate(stmt, signal), signal), ) + if (signal?.aborted) return emptySummary() if (barrier.action === "denied") { return { ...emptySummary(), denied: barrier.reason } } From acbe96162aadae6563a98840ca06ac5645fd3886 Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 11 Aug 2026 16:50:43 +0300 Subject: [PATCH 11/17] submodule --- e2e/questdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/questdb b/e2e/questdb index c34600e79..b06dd711a 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit c34600e7974a7087a5f18996a267981682b4e27d +Subproject commit b06dd711ab829ad20969f245999ef16618f051f5 From aae8a7bfd368406366e64424fc3d4e2550c69f64 Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 11 Aug 2026 19:32:51 +0300 Subject: [PATCH 12/17] fix review findings: chart auto-refresh revert, stuck fetching flag, unreachable Off default, per-tick viewport reset - #2: make the implicit chart auto-refresh stamp a one-time migration via a persisted settings marker, so a cleared override never reverts to Auto on reload; carry the marker through tab import - #3: abortRound clears fetching/slotFetching when superseding a round, and forceRefresh routes through it, so a click followed by hide during the start jitter no longer strands a stuck spinner - #6: the notebook refresh menu renders unset as unselected and no longer swallows the explicit Off click - #1: grid runToken is the frame timestamp (run identity) instead of swappedAt, so auto-refresh swaps keep scroll and focus while a real run still resets; clamp the focused cell when a swap shrinks the dataset; drop the now-unused slotSwappedAt ledger Co-Authored-By: Claude Fable 5 --- .../ResultGrid/useGridKeyboardNav.ts | 12 +++ src/scenes/Editor/Monaco/importTabs.ts | 1 + .../Notebook/NotebookRefreshControl.tsx | 4 +- .../cellRefresh/cellRefreshEngine.test.ts | 85 ++++++++++++------- .../Notebook/cellRefresh/cellRefreshEngine.ts | 75 ++++++---------- .../result-table/InlineResultTable.tsx | 2 +- .../result-table/statementSlotView.test.ts | 6 +- .../result-table/statementSlotView.ts | 4 - src/store/notebook.test.ts | 31 +++++-- src/store/notebook.ts | 20 +++-- 10 files changed, 132 insertions(+), 108 deletions(-) diff --git a/src/components/ResultGrid/useGridKeyboardNav.ts b/src/components/ResultGrid/useGridKeyboardNav.ts index b9b66bbbf..3357089a5 100644 --- a/src/components/ResultGrid/useGridKeyboardNav.ts +++ b/src/components/ResultGrid/useGridKeyboardNav.ts @@ -111,6 +111,18 @@ export const useGridKeyboardNav = ( [], ) + // A refresh can shrink the dataset under the focus; clamp it back into + // bounds so navigation never anchors past the last row or column. + useEffect(() => { + setFocusedCell((cell) => { + if (!cell) return cell + if (rowCount === 0 || colCount === 0) return null + const row = Math.min(cell.row, rowCount - 1) + const col = Math.min(cell.col, colCount - 1) + return row === cell.row && col === cell.col ? cell : { row, col } + }) + }, [rowCount, colCount]) + const onKeyDown = useCallback( (e: React.KeyboardEvent) => { if (!focusedCell) return diff --git a/src/scenes/Editor/Monaco/importTabs.ts b/src/scenes/Editor/Monaco/importTabs.ts index 07d08c432..188745e03 100644 --- a/src/scenes/Editor/Monaco/importTabs.ts +++ b/src/scenes/Editor/Monaco/importTabs.ts @@ -309,6 +309,7 @@ const sanitizeNotebookSettings = ( } if (isAutoRefresh(item.autoRefreshDefault)) settings.autoRefreshDefault = item.autoRefreshDefault + if (item.autoRefreshMigrated === true) settings.autoRefreshMigrated = true return settings } diff --git a/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx b/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx index 2ff03ba33..4fdab7f7d 100644 --- a/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx +++ b/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx @@ -79,7 +79,7 @@ export const NotebookRefreshControl: React.FC = () => { } const handleSelectDefault = (value: AutoRefresh | undefined) => { - if (value === undefined || value === (storedDefault ?? false)) return + if (value === undefined || value === storedDefault) return void trackEvent(ConsoleEvent.NOTEBOOK_AUTOREFRESH_DEFAULT_CHANGE, { from: storedDefault === undefined ? "unset" : autoRefreshLabel(storedDefault), @@ -127,7 +127,7 @@ export const NotebookRefreshControl: React.FC = () => { {overrideCount > 0 && ( diff --git a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts index 615923041..52ccf0290 100644 --- a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts +++ b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts @@ -1274,7 +1274,6 @@ describe("CellRefreshEngine", () => { slotErrors: new Map(), cancelledSlots: new Set(), slotFetchedAt: new Map(), - slotSwappedAt: new Map(), } // Then the recovery fetch after a failed restore shows the spinner @@ -1803,6 +1802,42 @@ describe("CellRefreshEngine", () => { random.mockRestore() }) + it("a superseding click leaves no stuck fetching flag when the cell hides during the start jitter", async () => { + // Given a jittered engine (deterministic 150ms) whose poll tick is + // mid-flight on a slow query + const random = vi.spyOn(Math, "random").mockReturnValue(0.5) + engine.destroy() + engine = new CellRefreshEngine(BUFFER_ID, () => deps as CellRefreshDeps, { + initialFetchJitterMs: 300, + }) + engine.attach() + engine.setVisible("c1", true) + engine.sync([drawCell("c1", "select 1", "30s")]) + await vi.advanceTimersByTimeAsync(150) + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + deps.executeSingle.mockImplementationOnce( + () => new Promise(() => undefined), + ) + await vi.advanceTimersByTimeAsync(30000) + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + expect(engine.isRefreshing("c1")).toBe(true) + + // When the click supersedes that round and the cell hides inside the + // replacement's start jitter + engine.refreshAll() + await vi.advanceTimersByTimeAsync(50) + engine.setVisible("c1", false) + await vi.advanceTimersByTimeAsync(5000) + + // Then the cell is not stuck refreshing, and the reveal redeems the click + expect(engine.isRefreshing("c1")).toBe(false) + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + engine.setVisible("c1", true) + await vi.advanceTimersByTimeAsync(150) + expect(deps.executeSingle).toHaveBeenCalledTimes(3) + random.mockRestore() + }) + it("the pending refresh dies when the cell leaves draw mode", async () => { // Given a settled Off cell that hid and got flagged syncOnScreen([drawCell("c1", "select 1", false)]) @@ -2129,36 +2164,31 @@ describe("CellRefreshEngine", () => { expect(written?.timestamp).toBe(0) }) - it("skips the commit and holds the swap token on identical rows, while the fetch time advances", async () => { + it("skips the commit on identical rows, while the fetch time advances", async () => { // Given a polling grid whose first tick swapped fresh rows in const cell = gridCell("g1", "select 1", ["select 1"], "1s") syncOnScreen([cell]) await flushAsync() expect(deps.setCellResult).toHaveBeenCalledTimes(1) - const state = engine.getState("g1") - const fetchedAfterSwap = state?.slotFetchedAt.get(keyOf("select 1")) - const swappedAfterSwap = state?.slotSwappedAt.get(keyOf("select 1")) + const fetchedAfterSwap = engine + .getState("g1") + ?.slotFetchedAt.get(keyOf("select 1")) expect(fetchedAfterSwap).toBeDefined() - expect(swappedAfterSwap).toBeDefined() // When later ticks return the same rows await vi.advanceTimersByTimeAsync(6000) - // Then the frame is never re-written and the swap token holds — a - // re-commit would reset the tab's scroll and focus for identical data — - // while the fetch time keeps advancing for the status line + // Then the frame is never re-written — a re-commit would churn renders + // and snapshots for identical data — while the fetch time keeps + // advancing for the status line expect(deps.executeSingle.mock.calls.length).toBeGreaterThan(1) expect(deps.setCellResult).toHaveBeenCalledTimes(1) - const settled = engine.getState("g1") - expect(settled?.slotSwappedAt.get(keyOf("select 1"))).toBe( - swappedAfterSwap, - ) expect( - settled?.slotFetchedAt.get(keyOf("select 1")) ?? 0, + engine.getState("g1")?.slotFetchedAt.get(keyOf("select 1")) ?? 0, ).toBeGreaterThan(fetchedAfterSwap ?? 0) }) - it("commits and advances the swap token when a tick returns changed rows", async () => { + it("commits again when a tick returns changed rows", async () => { // Given a polling grid whose data moves every tick let tick = 0 deps.executeSingle.mockImplementation((sql: string) => { @@ -2175,21 +2205,15 @@ describe("CellRefreshEngine", () => { syncOnScreen([cell]) await flushAsync() const writesAfterSwap = deps.setCellResult.mock.calls.length - const swappedAfterSwap = engine - .getState("g1") - ?.slotSwappedAt.get(keyOf("select 1")) - expect(swappedAfterSwap).toBeDefined() + expect(writesAfterSwap).toBeGreaterThan(0) // When the next tick lands with different rows await vi.advanceTimersByTimeAsync(6000) - // Then the slot commits again and its swap token advances + // Then the slot commits the changed rows into the frame expect(deps.setCellResult.mock.calls.length).toBeGreaterThan( writesAfterSwap, ) - expect( - engine.getState("g1")?.slotSwappedAt.get(keyOf("select 1")) ?? 0, - ).toBeGreaterThan(swappedAfterSwap ?? 0) }) it("clears a slot's refresh error without a commit when identical rows confirm recovery", async () => { @@ -2227,12 +2251,11 @@ describe("CellRefreshEngine", () => { await flushAsync() // Then the badge clears and the fetch time records the verifying poll, - // while the frame and its swap token stay untouched + // while the frame stays untouched const state = engine.getState("g1") expect(state?.slotErrors.size).toBe(0) expect(deps.setCellResult).not.toHaveBeenCalled() expect(state?.slotFetchedAt.get(keyOf("select 1"))).toBeDefined() - expect(state?.slotSwappedAt.get(keyOf("select 1"))).toBeUndefined() }) it("never registers an editor-only run cell", async () => { @@ -2930,18 +2953,16 @@ describe("CellRefreshEngine", () => { await flushAsync() const key = keyOf("select 1") expect(engine.getState("g1")?.slotFetchedAt.get(key)).toBeDefined() - expect(engine.getState("g1")?.slotSwappedAt.get(key)).toBeDefined() // When a run commits a new frame engine.noteCellRan("g1") - // Then the stamps are gone — the status line and the viewport token - // must follow the run, not the superseded refresh round + // Then the stamps are gone — the status line must follow the run, not + // the superseded refresh round expect(engine.getState("g1")?.slotFetchedAt.size).toBe(0) - expect(engine.getState("g1")?.slotSwappedAt.size).toBe(0) }) - it("a per-statement rerun clears only that statement's stamps", async () => { + it("a per-statement rerun clears only that statement's stamp", async () => { // Given a two-statement grid with refresh stamps on both slots changingResults() const cell = gridCell( @@ -2959,12 +2980,10 @@ describe("CellRefreshEngine", () => { // When one statement is rerun engine.noteStatementRan("g1", keyOf("select 1")) - // Then only its stamps drop; the sibling keeps its refresh times + // Then only its stamp drops; the sibling keeps its refresh time const state = engine.getState("g1") expect(state?.slotFetchedAt.has(keyOf("select 1"))).toBe(false) - expect(state?.slotSwappedAt.has(keyOf("select 1"))).toBe(false) expect(state?.slotFetchedAt.has(keyOf("select 2"))).toBe(true) - expect(state?.slotSwappedAt.has(keyOf("select 2"))).toBe(true) }) it("a superseded round's late settle leaves the replacement round's slot state intact", async () => { diff --git a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts index ceeaf0f06..310efb87b 100644 --- a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts +++ b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts @@ -82,11 +82,6 @@ export type CellFetchState = { // after a partial round the succeeded slots are newer than their siblings. // Memory-only — a reload falls back to the frame's saved time. slotFetchedAt: ReadonlyMap - // Wall-clock of each slot's last settle that CHANGED its rows. This is the - // grid's viewport/focus reset token: an identical refresh advances - // slotFetchedAt (the status line must show the poll that verified the rows) - // but not this, so the user keeps their scroll position. - slotSwappedAt: ReadonlyMap } export type CellRefreshDeps = { @@ -135,7 +130,6 @@ export const pendingCellFetchState = (sql: string): CellFetchState => { slotErrors: new Map(), cancelledSlots: new Set(), slotFetchedAt: new Map(), - slotSwappedAt: new Map(), } } @@ -426,9 +420,8 @@ export class CellRefreshEngine { } entry.pendingManualRefresh = false this.promotePendingSql(entry) + this.abortRound(entry) entry.manualRefreshInFlight = true - entry.inFlight?.abort() - entry.inFlight = null if (this.shouldPoll(entry)) { entry.lastFetchedAt = 0 entry.poll?.abort() @@ -547,19 +540,16 @@ export class CellRefreshEngine { this.dropPendingSnapshot(entry) entry.lastSnapshotAt = Date.now() // The refresh stamps describe rounds the run just superseded: leaving - // them would show the old refresh time under the run's rows and let a - // pre-run viewport restore onto them. Cleared, both fall back to the - // frame's own run timestamp. + // them would show the old refresh time under the run's rows. Cleared, the + // status line falls back to the frame's own run timestamp. if ( entry.state.slotErrors.size === 0 && - entry.state.slotFetchedAt.size === 0 && - entry.state.slotSwappedAt.size === 0 + entry.state.slotFetchedAt.size === 0 ) return this.setState(entry, { slotErrors: new Map(), slotFetchedAt: new Map(), - slotSwappedAt: new Map(), }) } @@ -574,7 +564,7 @@ export class CellRefreshEngine { this.dropPendingSnapshot(entry) entry.lastSnapshotAt = Date.now() this.clearSlotError(entry, statementKey) - this.clearSlotStamps(entry, statementKey) + this.clearSlotFetchStamp(entry, statementKey) if (entry.state.slotErrors.size > 0) this.persistGridFrame(entry, true) } @@ -590,9 +580,6 @@ export class CellRefreshEngine { if (!entry) return this.abortRound(entry) entry.manualRefreshInFlight = false - if (entry.state.fetching || entry.state.slotFetching.size > 0) { - this.setState(entry, { fetching: false, slotFetching: new Set() }) - } } // Balances noteRunStarted on EVERY outcome — denied, skipped, superseded, @@ -709,11 +696,18 @@ export class CellRefreshEngine { this.notify(cellId) } + // Superseding a round also clears its progress flags: the aborted round's + // finishRound no-ops (inFlight is nulled here), so nothing else would — a + // replacement round deferred behind the poll jitter can die with the poll, + // and `fetching` would stay stuck on for a hidden cell. private abortRound(entry: Entry) { entry.inFlight?.abort() entry.inFlight = null for (const abort of entry.slotAborts.values()) abort.abort() entry.slotAborts.clear() + if (entry.state.fetching || entry.state.slotFetching.size > 0) { + this.setState(entry, { fetching: false, slotFetching: new Set() }) + } } private applySqlState(entry: Entry, sql: string) { @@ -741,9 +735,6 @@ export class CellRefreshEngine { const slotFetchedAt = new Map( [...entry.state.slotFetchedAt].filter(([key]) => slotKeys.has(key)), ) - const slotSwappedAt = new Map( - [...entry.state.slotSwappedAt].filter(([key]) => slotKeys.has(key)), - ) this.setState(entry, { queries, queriesKey, @@ -752,7 +743,6 @@ export class CellRefreshEngine { cancelledSlots: new Set(), slotErrors, slotFetchedAt, - slotSwappedAt, ...(sameQueries ? { settledKey: queriesKey } : {}), }) if (entry.kind === "grid") this.ensureClassified(entry) @@ -1231,16 +1221,15 @@ export class CellRefreshEngine { this.setSlotError(entry, key, exec.error ?? "Query failed") } else { // The fetch time always advances — the status line shows the - // poll that just verified the rows. The swap token advances - // only when the rows changed: it drives the grid's - // viewport/focus reset, and an identical frame must not cost - // the user their scroll position. + // poll that just verified the rows. The frame is rewritten only + // when the rows changed, so an identical poll costs no renders + // and no snapshot churn. const previous = this.currentSlotResult(entry.cellId, key) const unchanged = previous !== undefined && resultsEquivalent([toExecResult(previous)], [exec]) if (!unchanged) this.commitSlotResult(entry, key, exec) - this.settleSlotSuccess(entry, key, !unchanged) + this.settleSlotSuccess(entry, key) } } catch (e) { if (slotAbort.signal.aborted || round.signal.aborted) return @@ -1316,9 +1305,9 @@ export class CellRefreshEngine { currentKeys[ Math.min(Math.max(current.activeResultIndex, 0), currentKeys.length - 1) ] - // The frame timestamp is every sibling tab's viewport token: bumping it - // per slot settle would reset their scroll. The settled slot's own token - // advances through slotFetchedAt instead. + // The frame timestamp is the grid's viewport token for every tab: + // bumping it per slot settle would reset scroll and focus. A settled + // slot's freshness advances through slotFetchedAt instead. deps.setCellResult(entry.cellId, { ...current, results: nextResults, @@ -1356,33 +1345,21 @@ export class CellRefreshEngine { this.setState(entry, { slotErrors }) } - private clearSlotStamps(entry: Entry, key: StatementKey) { - if ( - !entry.state.slotFetchedAt.has(key) && - !entry.state.slotSwappedAt.has(key) - ) - return + private clearSlotFetchStamp(entry: Entry, key: StatementKey) { + if (!entry.state.slotFetchedAt.has(key)) return const slotFetchedAt = new Map(entry.state.slotFetchedAt) - const slotSwappedAt = new Map(entry.state.slotSwappedAt) slotFetchedAt.delete(key) - slotSwappedAt.delete(key) - this.setState(entry, { slotFetchedAt, slotSwappedAt }) + this.setState(entry, { slotFetchedAt }) } - // A settle is one patch — stamps, error clear and fetching flip land in a - // single notify, so a round of S slots costs S renders, not 5S. - private settleSlotSuccess(entry: Entry, key: StatementKey, swapped: boolean) { - const now = Date.now() + // A settle is one patch — stamp, error clear and fetching flip land in a + // single notify, so a round of S slots costs S renders, not 3S. + private settleSlotSuccess(entry: Entry, key: StatementKey) { const slotFetchedAt = new Map(entry.state.slotFetchedAt) - slotFetchedAt.set(key, now) + slotFetchedAt.set(key, Date.now()) const slotFetching = new Set(entry.state.slotFetching) slotFetching.delete(key) const patch: Partial = { slotFetchedAt, slotFetching } - if (swapped) { - const slotSwappedAt = new Map(entry.state.slotSwappedAt) - slotSwappedAt.set(key, now) - patch.slotSwappedAt = slotSwappedAt - } if (entry.state.slotErrors.has(key)) { const slotErrors = new Map(entry.state.slotErrors) slotErrors.delete(key) diff --git a/src/scenes/Editor/Notebook/result-table/InlineResultTable.tsx b/src/scenes/Editor/Notebook/result-table/InlineResultTable.tsx index 8b2bad456..bc50e5e79 100644 --- a/src/scenes/Editor/Notebook/result-table/InlineResultTable.tsx +++ b/src/scenes/Editor/Notebook/result-table/InlineResultTable.tsx @@ -68,7 +68,7 @@ export const InlineResultTable: React.FC = ({ key={activeSlot.key} data={activeResult} viewportKey={activeSlot.key} - runToken={activeSlot.swappedAt ?? timestamp} + runToken={timestamp} isFocused={isFocused} bufferId={bufferId} cellId={cellId} diff --git a/src/scenes/Editor/Notebook/result-table/statementSlotView.test.ts b/src/scenes/Editor/Notebook/result-table/statementSlotView.test.ts index 7bceb2b65..027371fc5 100644 --- a/src/scenes/Editor/Notebook/result-table/statementSlotView.test.ts +++ b/src/scenes/Editor/Notebook/result-table/statementSlotView.test.ts @@ -29,7 +29,6 @@ const fetchState = (over: Partial = {}): CellFetchState => ({ slotErrors: new Map(), cancelledSlots: new Set(), slotFetchedAt: new Map(), - slotSwappedAt: new Map(), ...over, }) @@ -44,23 +43,20 @@ describe("buildStatementSlotViews", () => { slotFetching: new Set([key2]), slotErrors: new Map([[key1, "boom"]]), slotFetchedAt: new Map([[key1, 1234]]), - slotSwappedAt: new Map([[key1, 1200]]), }) // When the slot views are built const slots = buildStatementSlotViews(frame, state) - // Then each slot carries its own refresh state, freshness, and swap token + // Then each slot carries its own refresh state and freshness expect(slots[0]).toMatchObject({ key: key1, refreshing: false, refreshError: "boom", fetchedAt: 1234, - swappedAt: 1200, }) expect(slots[1]).toMatchObject({ key: key2, refreshing: true }) expect(slots[1].refreshError).toBeUndefined() - expect(slots[1].swappedAt).toBeUndefined() }) it("marks a statement with no result as not run, with no refresh state", () => { diff --git a/src/scenes/Editor/Notebook/result-table/statementSlotView.ts b/src/scenes/Editor/Notebook/result-table/statementSlotView.ts index 0b274b295..2a98cbee5 100644 --- a/src/scenes/Editor/Notebook/result-table/statementSlotView.ts +++ b/src/scenes/Editor/Notebook/result-table/statementSlotView.ts @@ -13,8 +13,6 @@ export type StatementSlotView = { refreshError?: string // Last successful poll — the status line's time. fetchedAt?: number - // Last poll that changed the rows — the grid's viewport/focus reset token. - swappedAt?: number } export const buildStatementSlotViews = ( @@ -24,7 +22,6 @@ export const buildStatementSlotViews = ( frame.slots.map((slot) => { const refreshError = fetchState?.slotErrors.get(slot.key) const fetchedAt = fetchState?.slotFetchedAt.get(slot.key) - const swappedAt = fetchState?.slotSwappedAt.get(slot.key) return { key: slot.key, sql: slot.sql, @@ -32,6 +29,5 @@ export const buildStatementSlotViews = ( refreshing: fetchState?.slotFetching.has(slot.key) ?? false, ...(refreshError !== undefined ? { refreshError } : {}), ...(fetchedAt !== undefined ? { fetchedAt } : {}), - ...(swappedAt !== undefined ? { swappedAt } : {}), } }) diff --git a/src/store/notebook.test.ts b/src/store/notebook.test.ts index 10dae7372..d24135b5d 100644 --- a/src/store/notebook.test.ts +++ b/src/store/notebook.test.ts @@ -89,20 +89,33 @@ describe("migrateImplicitChartAutoRefresh", () => { settings: { autoRefreshDefault: "30s" }, } - // Then the migration leaves it exactly as-is - expect(migrateImplicitChartAutoRefresh(state)).toBe(state) + // When the notebook loads + const result = migrateImplicitChartAutoRefresh(state) + + // Then the cells stay exactly as-is and the default survives + expect(result.cells).toBe(state.cells) + expect(result.settings?.autoRefreshDefault).toBe("30s") }) - it("returns the same state when nothing needs the stamp, and is idempotent", () => { - // Given only explicit charts and run cells + it("marks the view migrated so the stamp runs only once", () => { + // Given an unmigrated notebook + const result = migrateImplicitChartAutoRefresh({ cells: [chart("a")] }) + + // Then the view carries the marker and a second pass is identity + expect(result.settings?.autoRefreshMigrated).toBe(true) + expect(migrateImplicitChartAutoRefresh(result)).toBe(result) + }) + + it("never re-stamps a migrated view, so a cleared chart override survives a reload", () => { + // Given a migrated notebook whose chart the user reset to "inherit" const state: NotebookViewState = { - cells: [chart("a", false), cell({ id: "r", mode: "run" })], + cells: [chart("a")], + settings: { autoRefreshMigrated: true }, } - expect(migrateImplicitChartAutoRefresh(state)).toBe(state) - // And a stamped notebook does not change on a second pass - const stamped = migrateImplicitChartAutoRefresh({ cells: [chart("b")] }) - expect(migrateImplicitChartAutoRefresh(stamped)).toBe(stamped) + // When the notebook loads again + // Then the chart stays inherited instead of reverting to Auto + expect(migrateImplicitChartAutoRefresh(state)).toBe(state) }) }) diff --git a/src/store/notebook.ts b/src/store/notebook.ts index e7cde199e..cf63afc98 100644 --- a/src/store/notebook.ts +++ b/src/store/notebook.ts @@ -134,6 +134,7 @@ export type NotebookSettings = { layout?: CellLayoutItem[] variables?: NotebookVariable[] autoRefreshDefault?: AutoRefresh + autoRefreshMigrated?: true } export type NotebookViewState = { @@ -208,15 +209,24 @@ export const chartAutoRefreshStamp = ( // Charts drawn before the uniform-Off fallback polled through an implicit // per-view Auto. The stamp makes that liveness explicit so they keep polling; // grids never polled, and stay off. No notebook default is ever synthesized. +// The marker makes the stamp one-time: on a migrated view an undefined +// autoRefresh is a deliberate "inherit", and re-stamping it would silently +// revert a cleared chart to Auto. export const migrateImplicitChartAutoRefresh = ( state: NotebookViewState, ): NotebookViewState => { + if (state.settings?.autoRefreshMigrated) return state const autoRefreshDefault = state.settings?.autoRefreshDefault const needsStamp = (cell: NotebookCell) => Object.keys(chartAutoRefreshStamp(cell, autoRefreshDefault)).length > 0 - if (!state.cells.some(needsStamp)) return state - const cells = state.cells.map((cell) => - needsStamp(cell) ? { ...cell, autoRefresh: true as const } : cell, - ) - return { ...state, cells } + const cells = state.cells.some(needsStamp) + ? state.cells.map((cell) => + needsStamp(cell) ? { ...cell, autoRefresh: true as const } : cell, + ) + : state.cells + return { + ...state, + cells, + settings: { ...state.settings, autoRefreshMigrated: true }, + } } From 9c8029a5b99d0447ddea822039938752557ba3b6 Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 11 Aug 2026 23:09:08 +0300 Subject: [PATCH 13/17] fix born-live chart stamps reading as user overrides The stamp a draw switch writes is byte-identical to a user's explicit Auto, so with no notebook default it counted as an override: reset stripped it and silently froze every chart, and an agent apply echoing read state re-stamped a deliberately-inheriting chart back to Auto. isChartAutoRefreshStamp tells the two apart. The override count skips stamps, the notebook-level reset returns draw cells to born-live instead of freezing them (per-cell inherit stays a plain strip), and apply stamps only cells becoming charts - new or run-to-draw, mirroring the editor - so a read/apply round-trip preserves inherit. Co-Authored-By: Claude Fable 5 --- .../Editor/Notebook/NotebookProvider.tsx | 12 +- .../Notebook/NotebookRefreshControl.tsx | 2 +- .../Editor/Notebook/notebookUtils.test.ts | 157 +++++++++++++++++- src/scenes/Editor/Notebook/notebookUtils.ts | 45 ++++- src/store/notebook.ts | 10 ++ 5 files changed, 208 insertions(+), 18 deletions(-) diff --git a/src/scenes/Editor/Notebook/NotebookProvider.tsx b/src/scenes/Editor/Notebook/NotebookProvider.tsx index 6adc0367c..a426367b1 100644 --- a/src/scenes/Editor/Notebook/NotebookProvider.tsx +++ b/src/scenes/Editor/Notebook/NotebookProvider.tsx @@ -43,11 +43,11 @@ import { } from "../../../utils/notebooks/notebookController" import { type CellRunOutcome, - clearCellAutoRefresh, computeResultBottomHeight, countAutoRefreshOverrides, generateId, releaseCellResultPatch, + resetCellAutoRefresh, snapshotResultsMatchQueries, statementKeysFor, } from "./notebookUtils" @@ -778,10 +778,16 @@ export const NotebookProvider: React.FC<{ ) const resetAutoRefreshOverrides = useCallback(() => { - const count = countAutoRefreshOverrides(store.cellsRef.current) + const autoRefreshDefault = settingsRef.current.autoRefreshDefault + const count = countAutoRefreshOverrides( + store.cellsRef.current, + autoRefreshDefault, + ) if (count === 0) return signalUserEdit(bufferId) - store.updateCells((prev) => prev.map(clearCellAutoRefresh)) + store.updateCells((prev) => + prev.map((cell) => resetCellAutoRefresh(cell, autoRefreshDefault)), + ) void trackEvent(ConsoleEvent.NOTEBOOK_AUTOREFRESH_RESET_OVERRIDES, { count, }) diff --git a/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx b/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx index 4fdab7f7d..a036bf993 100644 --- a/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx +++ b/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx @@ -51,7 +51,7 @@ export const NotebookRefreshControl: React.FC = () => { const cellRefresh = useCellRefresh() const storedDefault = settings.autoRefreshDefault const defaultLabel = autoRefreshLabel(storedDefault ?? false) - const overrideCount = countActiveAutoRefreshOverrides(cells) + const overrideCount = countActiveAutoRefreshOverrides(cells, storedDefault) const refreshableCellCount = cells.filter( (cell) => resolveCellView(cell) !== "none", ).length diff --git a/src/scenes/Editor/Notebook/notebookUtils.test.ts b/src/scenes/Editor/Notebook/notebookUtils.test.ts index 33fe2deff..423866171 100644 --- a/src/scenes/Editor/Notebook/notebookUtils.test.ts +++ b/src/scenes/Editor/Notebook/notebookUtils.test.ts @@ -19,6 +19,7 @@ import { cellToolbarMenuFlags, cellToolbarTier, clearCellAutoRefresh, + resetCellAutoRefresh, cloneNotebookViewState, countActiveAutoRefreshOverrides, countAutoRefreshOverrides, @@ -2669,9 +2670,30 @@ describe("auto-refresh inheritance helpers", () => { cell("c", "SELECT 3"), ] // Then the count matches what a reset would clear - expect(countAutoRefreshOverrides(cells)).toBe(2) - expect(isAutoRefreshOverride(cells[1])).toBe(true) - expect(isAutoRefreshOverride(cells[2])).toBe(false) + expect(countAutoRefreshOverrides(cells, undefined)).toBe(2) + expect(isAutoRefreshOverride(cells[1], undefined)).toBe(true) + expect(isAutoRefreshOverride(cells[2], undefined)).toBe(false) + }) + + it("born-live chart stamp is not an override while the default is unset, and becomes one once a default is stored", () => { + // Given a freshly drawn chart carrying the born-live stamp + const stamped: NotebookCell = { + ...cell("a", "SELECT 1"), + mode: "draw", + autoRefresh: true, + } + // Then with no notebook default it reads as the default state, not an override + expect(isAutoRefreshOverride(stamped, undefined)).toBe(false) + expect(countAutoRefreshOverrides([stamped], undefined)).toBe(0) + // And once the notebook stores a default, the same value is a real override + expect(isAutoRefreshOverride(stamped, "5s")).toBe(true) + // A run cell with true is never a stamp + expect( + isAutoRefreshOverride( + { ...cell("b", "SELECT 2"), mode: "run", autoRefresh: true }, + undefined, + ), + ).toBe(true) }) it("countActiveAutoRefreshOverrides counts overrides on cells showing a view — editor-only keys stay dormant", () => { @@ -2695,9 +2717,9 @@ describe("auto-refresh inheritance helpers", () => { { ...cell("c", "SELECT 3"), mode: "run", autoRefresh: "1s" }, ] // Then only the cells with a visible view count toward the displayed total - expect(countActiveAutoRefreshOverrides(cells)).toBe(2) + expect(countActiveAutoRefreshOverrides(cells, undefined)).toBe(2) // And a notebook with only editor-only keys shows no override at all - expect(countActiveAutoRefreshOverrides([cells[2]])).toBe(0) + expect(countActiveAutoRefreshOverrides([cells[2]], undefined)).toBe(0) }) it("clearCellAutoRefresh deletes the key so a later draw switch cannot resurrect it", () => { @@ -2723,6 +2745,59 @@ describe("auto-refresh inheritance helpers", () => { const c = cell("a", "SELECT 1") expect(clearCellAutoRefresh(c)).toBe(c) }) + + it("clearCellAutoRefresh strips a chart stamp — the per-cell inherit choice is deliberate", () => { + // Given a born-live chart, no notebook default + const stamped: NotebookCell = { + ...cell("a", "SELECT 1"), + mode: "draw", + autoRefresh: true, + } + // When the user picks "Notebook default" on the cell + const cleared = clearCellAutoRefresh(stamped) + // Then the key is gone — the chart inherits Off and is never re-stamped + expect("autoRefresh" in cleared).toBe(false) + }) + + it("resetCellAutoRefresh returns a chart to born-live under an unset default instead of freezing it", () => { + // Given a chart the user pinned to a fixed interval, no notebook default + const pinned: NotebookCell = { + ...cell("a", "SELECT 1"), + mode: "draw", + autoRefresh: "10s", + } + // When the notebook-level reset clears the override + const reset = resetCellAutoRefresh(pinned, undefined) + // Then the chart lands on the born-live default, still polling + expect(reset.autoRefresh).toBe(true) + }) + + it("resetCellAutoRefresh leaves the born-live stamp untouched under an unset default", () => { + const stamped: NotebookCell = { + ...cell("a", "SELECT 1"), + mode: "draw", + autoRefresh: true, + } + expect(resetCellAutoRefresh(stamped, undefined)).toBe(stamped) + }) + + it("resetCellAutoRefresh drops a chart override to inherit when a default is stored", () => { + // Given a chart pinned to Auto while the notebook default is 5s + const pinned: NotebookCell = { + ...cell("a", "SELECT 1"), + mode: "draw", + autoRefresh: true, + } + // When the notebook-level reset clears the override + const reset = resetCellAutoRefresh(pinned, "5s") + // Then no key remains — the chart inherits the notebook default + expect("autoRefresh" in reset).toBe(false) + }) + + it("resetCellAutoRefresh preserves a deliberate inherit — reset only touches overrides", () => { + const inheriting: NotebookCell = { ...cell("a", "SELECT 1"), mode: "draw" } + expect(resetCellAutoRefresh(inheriting, undefined)).toBe(inheriting) + }) }) describe("resolveCellView", () => { @@ -3240,6 +3315,78 @@ describe("buildAppliedNotebookState", () => { expect(preserved.settings.autoRefreshDefault).toBe("30s") }) + const chart = (id: string): NotebookCell => ({ + ...cell(id, "SELECT 1"), + mode: "draw", + chartConfig: { + xColumn: "ts", + queries: [{ type: "line", yColumns: ["v"] }], + }, + }) + const chartRequestCell = { + value: "SELECT 1", + mode: "draw" as const, + chartConfig: { + xColumn: "ts", + queries: [{ type: "line" as const, yColumns: ["v"] }], + }, + } + + it("stamps a new draw cell born-live when the default is unset", () => { + // Given a notebook with no auto-refresh default + const current = state([cell("a", "SELECT 1")]) + // When apply adds a chart without auto_refresh + const next = buildAppliedNotebookState(current, { + cells: [{ id: "a", preserveValue: true }, chartRequestCell], + }) + // Then the new chart polls born-live, like the editor's draw switch + expect(next.cells[1].autoRefresh).toBe(true) + }) + + it("stamps an existing run cell converted to draw, like the editor's draw switch", () => { + // Given an existing run cell + const current = state([cell("a", "SELECT 1")]) + // When apply turns it into a chart without auto_refresh + const next = buildAppliedNotebookState(current, { + cells: [{ ...chartRequestCell, id: "a" }], + }) + // Then the converted chart polls born-live + expect(next.cells[0].autoRefresh).toBe(true) + }) + + it("echoing an inheriting chart does not re-stamp it — a read/apply round-trip preserves inherit", () => { + // Given a chart the user set back to the notebook default (unset ⇒ Off) + const current = state([chart("a")]) + // When an agent echoes the notebook state without auto_refresh + const next = buildAppliedNotebookState(current, { + cells: [{ ...chartRequestCell, id: "a" }], + }) + // Then the chart still inherits — it does not flip to Auto + expect("autoRefresh" in next.cells[0]).toBe(false) + }) + + it("echoing a chart's explicit auto_refresh keeps it", () => { + // Given a born-live chart carrying its stamp + const current = state([{ ...chart("a"), autoRefresh: true as const }]) + // When an agent echoes the stamped value + const next = buildAppliedNotebookState(current, { + cells: [{ ...chartRequestCell, id: "a", autoRefresh: true }], + }) + // Then the chart keeps polling + expect(next.cells[0].autoRefresh).toBe(true) + }) + + it("apply clears a chart's explicit auto_refresh to inherit when omitted", () => { + // Given a chart pinned to a fixed interval + const current = state([{ ...chart("a"), autoRefresh: "5s" as const }]) + // When an agent applies the cell without auto_refresh + const next = buildAppliedNotebookState(current, { + cells: [{ ...chartRequestCell, id: "a" }], + }) + // Then the override clears — PUT semantics, no re-stamp + expect("autoRefresh" in next.cells[0]).toBe(false) + }) + it("grid layout mode builds a layout for every cell", () => { // Given a list-mode notebook const current = state([cell("a", "SELECT 1")]) diff --git a/src/scenes/Editor/Notebook/notebookUtils.ts b/src/scenes/Editor/Notebook/notebookUtils.ts index ef2c39277..4cbcd78e6 100644 --- a/src/scenes/Editor/Notebook/notebookUtils.ts +++ b/src/scenes/Editor/Notebook/notebookUtils.ts @@ -15,6 +15,7 @@ import type { import { AUTO_REFRESH_INTERVALS, chartAutoRefreshStamp, + isChartAutoRefreshStamp, createCell, MAX_NOTEBOOK_CELLS, MAX_CELL_LINES, @@ -64,25 +65,48 @@ export const resolveAutoRefresh = ( ): AutoRefresh => cellValue ?? notebookDefault ?? false export const isAutoRefreshOverride = ( - cell: Pick, -): boolean => cell.autoRefresh !== undefined + cell: Pick, + autoRefreshDefault: AutoRefresh | undefined, +): boolean => + cell.autoRefresh !== undefined && + !isChartAutoRefreshStamp(cell, autoRefreshDefault) -export const countAutoRefreshOverrides = (cells: NotebookCell[]): number => - cells.filter(isAutoRefreshOverride).length +export const countAutoRefreshOverrides = ( + cells: NotebookCell[], + autoRefreshDefault: AutoRefresh | undefined, +): number => + cells.filter((cell) => isAutoRefreshOverride(cell, autoRefreshDefault)).length export const countActiveAutoRefreshOverrides = ( cells: NotebookCell[], + autoRefreshDefault: AutoRefresh | undefined, ): number => cells.filter( - (cell) => resolveCellView(cell) !== "none" && isAutoRefreshOverride(cell), + (cell) => + resolveCellView(cell) !== "none" && + isAutoRefreshOverride(cell, autoRefreshDefault), ).length +// Plain strip for the per-cell "Notebook default" choice — a deliberate +// inherit is never re-stamped. export const clearCellAutoRefresh = (cell: NotebookCell): NotebookCell => { - if (!isAutoRefreshOverride(cell)) return cell + if (cell.autoRefresh === undefined) return cell const { autoRefresh: _, ...rest } = cell return rest } +// The notebook-level reset instead returns each cell to its default state, +// and for a draw cell under an unset default that state is born-live — the +// stamp comes back rather than leaving the chart silently frozen. +export const resetCellAutoRefresh = ( + cell: NotebookCell, + autoRefreshDefault: AutoRefresh | undefined, +): NotebookCell => { + if (!isAutoRefreshOverride(cell, autoRefreshDefault)) return cell + const cleared = clearCellAutoRefresh(cell) + return { ...cleared, ...chartAutoRefreshStamp(cleared, autoRefreshDefault) } +} + // What a cell currently shows in its bottom slot — drives the toolbar's // view-switch / refresh / chart actions and their disabled states. export type CellView = "none" | "grid" | "chart" @@ -1801,10 +1825,13 @@ export const buildAppliedNotebookState = ( nextMaximizedCellId = null } - // Apply is a PUT: an omitted auto_refresh cleared any prior override, so - // draw cells are re-stamped against the post-apply default — charts stay - // born-live no matter who composed the state. + // Apply is a PUT: an omitted auto_refresh clears any prior override. Cells + // becoming charts (new, or run→draw) are stamped born-live, mirroring the + // editor's draw switch — but a cell that already was a chart keeps its + // cleared state, so an agent echoing read state cannot flip an inheriting + // chart back to Auto. const stampedCells = cells.map((cell) => { + if (prevById.get(cell.id)?.mode === "draw") return cell const stamp = chartAutoRefreshStamp(cell, nextSettings.autoRefreshDefault) return "autoRefresh" in stamp ? { ...cell, ...stamp } : cell }) diff --git a/src/store/notebook.ts b/src/store/notebook.ts index cf63afc98..2e0f1695c 100644 --- a/src/store/notebook.ts +++ b/src/store/notebook.ts @@ -206,6 +206,16 @@ export const chartAutoRefreshStamp = ( ? { autoRefresh: true } : {} +// A stamped chart carries the same `autoRefresh: true` a user override would; +// this tells the two apart so born-live charts don't read as user overrides. +export const isChartAutoRefreshStamp = ( + cell: Pick, + autoRefreshDefault: AutoRefresh | undefined, +): boolean => + cell.mode === "draw" && + cell.autoRefresh === true && + autoRefreshDefault === undefined + // Charts drawn before the uniform-Off fallback polled through an implicit // per-view Auto. The stamp makes that liveness explicit so they keep polling; // grids never polled, and stay off. No notebook default is ever synthesized. From 09b81ecb17fac5df61d55a3502aae9e2b3092b05 Mon Sep 17 00:00:00 2001 From: emrberk Date: Wed, 12 Aug 2026 15:24:19 +0300 Subject: [PATCH 14/17] =?UTF-8?q?remove=20the=20born-live=20chart=20stamp?= =?UTF-8?q?=20=E2=80=94=20draw=20no=20longer=20implies=20auto-refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entering draw mode no longer writes autoRefresh: true onto the cell. autoRefresh now has one writer (the user) and one meaning: an override is any stored key. The legacy migration stays and writes an honest, resettable Auto so pre-existing charts keep polling after upgrade. This closes the stamp-leak bug (draw→run left a grid polling at 2s), the inert first default set on migrated notebooks, and the override-dot divergence between the cell button and the notebook toolbar. Co-Authored-By: Claude Fable 5 --- src/consts/shared-definitions.json | 6 +- .../Editor/Notebook/NotebookProvider.tsx | 12 +- .../Notebook/NotebookRefreshControl.tsx | 5 +- .../Editor/Notebook/notebookUtils.test.ts | 125 +++--------------- src/scenes/Editor/Notebook/notebookUtils.ts | 50 +------ src/store/notebook.ts | 44 ++---- src/utils/ai/notebookSnapshot.ts | 3 +- src/utils/ai/shared.notebookTools.test.ts | 43 ++---- .../notebookController/notebookTransitions.ts | 7 - 9 files changed, 54 insertions(+), 241 deletions(-) diff --git a/src/consts/shared-definitions.json b/src/consts/shared-definitions.json index a0c241282..9432c14f1 100644 --- a/src/consts/shared-definitions.json +++ b/src/consts/shared-definitions.json @@ -649,7 +649,7 @@ "surfaces": ["ai", "mcp"], "mutatesNotebook": true, "createsNotebook": false, - "description": "Set auto-refresh polling for a cell, as a per-cell override of the notebook default (set_notebook_autorefresh). Applies to both chart (draw-mode) and grid (run-mode) cells. A cell containing DDL/DML never polls: the value is stored, but the engine blocks its ticks and read tools report `auto_refresh_blocked: \"contains_write\"`. Markdown cells are rejected. Nothing polls without a per-cell value or a notebook default; a cell that becomes a chart while the notebook has no default is stamped with an explicit `true` so charts are born polling — that stamp is a normal per-cell value you can read and change. `value`: true = adaptive poll (interval auto-tuned to response time), false = no polling, a fixed interval string (\"1s\", \"5s\", \"10s\", \"30s\", \"1m\"), or null to clear the override so the cell inherits the notebook default.", + "description": "Set auto-refresh polling for a cell, as a per-cell override of the notebook default (set_notebook_autorefresh). Applies to both chart (draw-mode) and grid (run-mode) cells. A cell containing DDL/DML never polls: the value is stored, but the engine blocks its ticks and read tools report `auto_refresh_blocked: \"contains_write\"`. Markdown cells are rejected. Nothing polls without a per-cell value or a notebook default. `value`: true = adaptive poll (interval auto-tuned to response time), false = no polling, a fixed interval string (\"1s\", \"5s\", \"10s\", \"30s\", \"1m\"), or null to clear the override so the cell inherits the notebook default.", "inputSchema": { "type": "object", "additionalProperties": false, @@ -681,7 +681,7 @@ "surfaces": ["ai", "mcp"], "mutatesNotebook": true, "createsNotebook": false, - "description": "Set the notebook-level auto-refresh default for every cell showing a chart or a grid. Cells with no per-cell value inherit it; set_cell_autorefresh sets a per-cell override that wins over it. Cells containing DDL/DML are skipped — auto-refresh never executes a write. Until this is set, nothing polls except chart cells, which get an explicit per-cell `true` stamped when they become charts. `value`: true = adaptive poll (interval auto-tuned to response time), false = no polling, or a fixed interval string (\"1s\", \"5s\", \"10s\", \"30s\", \"1m\"). `reset_cell_overrides: true` additionally deletes every per-cell override in the same atomic call, so ALL cells follow the new default — the equivalent of the console's \"Reset cell overrides\" action. Nothing re-runs; false or null leaves per-cell overrides in place and they keep winning over the default.", + "description": "Set the notebook-level auto-refresh default for every cell showing a chart or a grid. Cells with no per-cell value inherit it; set_cell_autorefresh sets a per-cell override that wins over it. Cells containing DDL/DML are skipped — auto-refresh never executes a write. Until this is set, nothing polls. `value`: true = adaptive poll (interval auto-tuned to response time), false = no polling, or a fixed interval string (\"1s\", \"5s\", \"10s\", \"30s\", \"1m\"). `reset_cell_overrides: true` additionally deletes every per-cell override in the same atomic call, so ALL cells follow the new default — the equivalent of the console's \"Reset cell overrides\" action. Nothing re-runs; false or null leaves per-cell overrides in place and they keep winning over the default.", "inputSchema": { "type": "object", "additionalProperties": false, @@ -853,7 +853,7 @@ "enum": ["1s", "5s", "10s", "30s", "1m"] } ], - "description": "Per-cell auto-refresh value: true = adaptive poll, false = off, or a fixed interval string (\"1s\"/\"5s\"/\"10s\"/\"30s\"/\"1m\"). Omitted or null stores NO override — the cell inherits the notebook's auto_refresh_default; a mode='draw' cell with no override and no default is stamped `true` after the apply (charts are born polling)." + "description": "Per-cell auto-refresh value: true = adaptive poll, false = off, or a fixed interval string (\"1s\"/\"5s\"/\"10s\"/\"30s\"/\"1m\"). Omitted or null stores NO override — the cell inherits the notebook's auto_refresh_default." }, "is_view_maximized": { "type": ["boolean", "null"], diff --git a/src/scenes/Editor/Notebook/NotebookProvider.tsx b/src/scenes/Editor/Notebook/NotebookProvider.tsx index a426367b1..6adc0367c 100644 --- a/src/scenes/Editor/Notebook/NotebookProvider.tsx +++ b/src/scenes/Editor/Notebook/NotebookProvider.tsx @@ -43,11 +43,11 @@ import { } from "../../../utils/notebooks/notebookController" import { type CellRunOutcome, + clearCellAutoRefresh, computeResultBottomHeight, countAutoRefreshOverrides, generateId, releaseCellResultPatch, - resetCellAutoRefresh, snapshotResultsMatchQueries, statementKeysFor, } from "./notebookUtils" @@ -778,16 +778,10 @@ export const NotebookProvider: React.FC<{ ) const resetAutoRefreshOverrides = useCallback(() => { - const autoRefreshDefault = settingsRef.current.autoRefreshDefault - const count = countAutoRefreshOverrides( - store.cellsRef.current, - autoRefreshDefault, - ) + const count = countAutoRefreshOverrides(store.cellsRef.current) if (count === 0) return signalUserEdit(bufferId) - store.updateCells((prev) => - prev.map((cell) => resetCellAutoRefresh(cell, autoRefreshDefault)), - ) + store.updateCells((prev) => prev.map(clearCellAutoRefresh)) void trackEvent(ConsoleEvent.NOTEBOOK_AUTOREFRESH_RESET_OVERRIDES, { count, }) diff --git a/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx b/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx index a036bf993..75ac8c3b3 100644 --- a/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx +++ b/src/scenes/Editor/Notebook/NotebookRefreshControl.tsx @@ -51,7 +51,7 @@ export const NotebookRefreshControl: React.FC = () => { const cellRefresh = useCellRefresh() const storedDefault = settings.autoRefreshDefault const defaultLabel = autoRefreshLabel(storedDefault ?? false) - const overrideCount = countActiveAutoRefreshOverrides(cells, storedDefault) + const overrideCount = countActiveAutoRefreshOverrides(cells) const refreshableCellCount = cells.filter( (cell) => resolveCellView(cell) !== "none", ).length @@ -152,7 +152,8 @@ export const NotebookRefreshControl: React.FC = () => { <> - Charts poll on Auto; grids stay off until you set a default. + Cells refresh only when you set a notebook default or a + per-cell interval. )} diff --git a/src/scenes/Editor/Notebook/notebookUtils.test.ts b/src/scenes/Editor/Notebook/notebookUtils.test.ts index 423866171..8e6da93d7 100644 --- a/src/scenes/Editor/Notebook/notebookUtils.test.ts +++ b/src/scenes/Editor/Notebook/notebookUtils.test.ts @@ -19,11 +19,9 @@ import { cellToolbarMenuFlags, cellToolbarTier, clearCellAutoRefresh, - resetCellAutoRefresh, cloneNotebookViewState, countActiveAutoRefreshOverrides, countAutoRefreshOverrides, - isAutoRefreshOverride, resolveAutoRefresh, cloneNotebookViewStateWithCellIdMap, computeAgentCellGridH, @@ -2663,37 +2661,15 @@ describe("auto-refresh inheritance helpers", () => { }) it("countAutoRefreshOverrides counts stored keys on ANY mode, including dormant run-cell overrides", () => { - // Given a draw override, a dormant run-mode override, and an inheriting cell + // Given a draw override, an Auto chart, a dormant run-mode override, and an inheriting cell const cells: NotebookCell[] = [ { ...cell("a", "SELECT 1"), mode: "draw", autoRefresh: "5s" }, - { ...cell("b", "SELECT 2"), mode: "run", autoRefresh: false }, - cell("c", "SELECT 3"), + { ...cell("b", "SELECT 2"), mode: "draw", autoRefresh: true }, + { ...cell("c", "SELECT 3"), mode: "run", autoRefresh: false }, + cell("d", "SELECT 4"), ] - // Then the count matches what a reset would clear - expect(countAutoRefreshOverrides(cells, undefined)).toBe(2) - expect(isAutoRefreshOverride(cells[1], undefined)).toBe(true) - expect(isAutoRefreshOverride(cells[2], undefined)).toBe(false) - }) - - it("born-live chart stamp is not an override while the default is unset, and becomes one once a default is stored", () => { - // Given a freshly drawn chart carrying the born-live stamp - const stamped: NotebookCell = { - ...cell("a", "SELECT 1"), - mode: "draw", - autoRefresh: true, - } - // Then with no notebook default it reads as the default state, not an override - expect(isAutoRefreshOverride(stamped, undefined)).toBe(false) - expect(countAutoRefreshOverrides([stamped], undefined)).toBe(0) - // And once the notebook stores a default, the same value is a real override - expect(isAutoRefreshOverride(stamped, "5s")).toBe(true) - // A run cell with true is never a stamp - expect( - isAutoRefreshOverride( - { ...cell("b", "SELECT 2"), mode: "run", autoRefresh: true }, - undefined, - ), - ).toBe(true) + // Then every stored key counts — the count matches what a reset would clear + expect(countAutoRefreshOverrides(cells)).toBe(3) }) it("countActiveAutoRefreshOverrides counts overrides on cells showing a view — editor-only keys stay dormant", () => { @@ -2717,9 +2693,9 @@ describe("auto-refresh inheritance helpers", () => { { ...cell("c", "SELECT 3"), mode: "run", autoRefresh: "1s" }, ] // Then only the cells with a visible view count toward the displayed total - expect(countActiveAutoRefreshOverrides(cells, undefined)).toBe(2) + expect(countActiveAutoRefreshOverrides(cells)).toBe(2) // And a notebook with only editor-only keys shows no override at all - expect(countActiveAutoRefreshOverrides([cells[2]], undefined)).toBe(0) + expect(countActiveAutoRefreshOverrides([cells[2]])).toBe(0) }) it("clearCellAutoRefresh deletes the key so a later draw switch cannot resurrect it", () => { @@ -2746,58 +2722,18 @@ describe("auto-refresh inheritance helpers", () => { expect(clearCellAutoRefresh(c)).toBe(c) }) - it("clearCellAutoRefresh strips a chart stamp — the per-cell inherit choice is deliberate", () => { - // Given a born-live chart, no notebook default - const stamped: NotebookCell = { + it("clearCellAutoRefresh drops a chart's Auto so the cell inherits the notebook default", () => { + // Given a chart set to Auto, no notebook default + const chart: NotebookCell = { ...cell("a", "SELECT 1"), mode: "draw", autoRefresh: true, } // When the user picks "Notebook default" on the cell - const cleared = clearCellAutoRefresh(stamped) - // Then the key is gone — the chart inherits Off and is never re-stamped + const cleared = clearCellAutoRefresh(chart) + // Then the key is gone — the chart inherits (Off while the default is unset) expect("autoRefresh" in cleared).toBe(false) }) - - it("resetCellAutoRefresh returns a chart to born-live under an unset default instead of freezing it", () => { - // Given a chart the user pinned to a fixed interval, no notebook default - const pinned: NotebookCell = { - ...cell("a", "SELECT 1"), - mode: "draw", - autoRefresh: "10s", - } - // When the notebook-level reset clears the override - const reset = resetCellAutoRefresh(pinned, undefined) - // Then the chart lands on the born-live default, still polling - expect(reset.autoRefresh).toBe(true) - }) - - it("resetCellAutoRefresh leaves the born-live stamp untouched under an unset default", () => { - const stamped: NotebookCell = { - ...cell("a", "SELECT 1"), - mode: "draw", - autoRefresh: true, - } - expect(resetCellAutoRefresh(stamped, undefined)).toBe(stamped) - }) - - it("resetCellAutoRefresh drops a chart override to inherit when a default is stored", () => { - // Given a chart pinned to Auto while the notebook default is 5s - const pinned: NotebookCell = { - ...cell("a", "SELECT 1"), - mode: "draw", - autoRefresh: true, - } - // When the notebook-level reset clears the override - const reset = resetCellAutoRefresh(pinned, "5s") - // Then no key remains — the chart inherits the notebook default - expect("autoRefresh" in reset).toBe(false) - }) - - it("resetCellAutoRefresh preserves a deliberate inherit — reset only touches overrides", () => { - const inheriting: NotebookCell = { ...cell("a", "SELECT 1"), mode: "draw" } - expect(resetCellAutoRefresh(inheriting, undefined)).toBe(inheriting) - }) }) describe("resolveCellView", () => { @@ -3332,43 +3268,22 @@ describe("buildAppliedNotebookState", () => { }, } - it("stamps a new draw cell born-live when the default is unset", () => { + it("never synthesizes auto_refresh — new and converted charts inherit", () => { // Given a notebook with no auto-refresh default const current = state([cell("a", "SELECT 1")]) - // When apply adds a chart without auto_refresh - const next = buildAppliedNotebookState(current, { - cells: [{ id: "a", preserveValue: true }, chartRequestCell], - }) - // Then the new chart polls born-live, like the editor's draw switch - expect(next.cells[1].autoRefresh).toBe(true) - }) - - it("stamps an existing run cell converted to draw, like the editor's draw switch", () => { - // Given an existing run cell - const current = state([cell("a", "SELECT 1")]) - // When apply turns it into a chart without auto_refresh - const next = buildAppliedNotebookState(current, { - cells: [{ ...chartRequestCell, id: "a" }], - }) - // Then the converted chart polls born-live - expect(next.cells[0].autoRefresh).toBe(true) - }) - - it("echoing an inheriting chart does not re-stamp it — a read/apply round-trip preserves inherit", () => { - // Given a chart the user set back to the notebook default (unset ⇒ Off) - const current = state([chart("a")]) - // When an agent echoes the notebook state without auto_refresh + // When apply converts one cell to a chart and adds another, both without auto_refresh const next = buildAppliedNotebookState(current, { - cells: [{ ...chartRequestCell, id: "a" }], + cells: [{ ...chartRequestCell, id: "a" }, chartRequestCell], }) - // Then the chart still inherits — it does not flip to Auto + // Then neither chart stores a key — both inherit the notebook default expect("autoRefresh" in next.cells[0]).toBe(false) + expect("autoRefresh" in next.cells[1]).toBe(false) }) it("echoing a chart's explicit auto_refresh keeps it", () => { - // Given a born-live chart carrying its stamp + // Given a chart set to Auto const current = state([{ ...chart("a"), autoRefresh: true as const }]) - // When an agent echoes the stamped value + // When an agent echoes the value const next = buildAppliedNotebookState(current, { cells: [{ ...chartRequestCell, id: "a", autoRefresh: true }], }) diff --git a/src/scenes/Editor/Notebook/notebookUtils.ts b/src/scenes/Editor/Notebook/notebookUtils.ts index 4cbcd78e6..5005be8b8 100644 --- a/src/scenes/Editor/Notebook/notebookUtils.ts +++ b/src/scenes/Editor/Notebook/notebookUtils.ts @@ -14,8 +14,6 @@ import type { } from "../../../store/notebook" import { AUTO_REFRESH_INTERVALS, - chartAutoRefreshStamp, - isChartAutoRefreshStamp, createCell, MAX_NOTEBOOK_CELLS, MAX_CELL_LINES, @@ -57,56 +55,29 @@ export const isAutoRefresh = (value: unknown): value is AutoRefresh => Object.prototype.hasOwnProperty.call(AUTO_REFRESH_INTERVALS, value)) // Terminal fallback is Off for every view: nothing polls unless the cell or -// the notebook says so. Charts stay live because entering draw mode stamps an -// explicit Auto onto the cell (chartAutoRefreshStamp). +// the notebook says so. export const resolveAutoRefresh = ( cellValue: AutoRefresh | undefined, notebookDefault: AutoRefresh | undefined, ): AutoRefresh => cellValue ?? notebookDefault ?? false -export const isAutoRefreshOverride = ( - cell: Pick, - autoRefreshDefault: AutoRefresh | undefined, -): boolean => - cell.autoRefresh !== undefined && - !isChartAutoRefreshStamp(cell, autoRefreshDefault) - -export const countAutoRefreshOverrides = ( - cells: NotebookCell[], - autoRefreshDefault: AutoRefresh | undefined, -): number => - cells.filter((cell) => isAutoRefreshOverride(cell, autoRefreshDefault)).length +export const countAutoRefreshOverrides = (cells: NotebookCell[]): number => + cells.filter((cell) => cell.autoRefresh !== undefined).length export const countActiveAutoRefreshOverrides = ( cells: NotebookCell[], - autoRefreshDefault: AutoRefresh | undefined, ): number => cells.filter( (cell) => - resolveCellView(cell) !== "none" && - isAutoRefreshOverride(cell, autoRefreshDefault), + resolveCellView(cell) !== "none" && cell.autoRefresh !== undefined, ).length -// Plain strip for the per-cell "Notebook default" choice — a deliberate -// inherit is never re-stamped. export const clearCellAutoRefresh = (cell: NotebookCell): NotebookCell => { if (cell.autoRefresh === undefined) return cell const { autoRefresh: _, ...rest } = cell return rest } -// The notebook-level reset instead returns each cell to its default state, -// and for a draw cell under an unset default that state is born-live — the -// stamp comes back rather than leaving the chart silently frozen. -export const resetCellAutoRefresh = ( - cell: NotebookCell, - autoRefreshDefault: AutoRefresh | undefined, -): NotebookCell => { - if (!isAutoRefreshOverride(cell, autoRefreshDefault)) return cell - const cleared = clearCellAutoRefresh(cell) - return { ...cleared, ...chartAutoRefreshStamp(cleared, autoRefreshDefault) } -} - // What a cell currently shows in its bottom slot — drives the toolbar's // view-switch / refresh / chart actions and their disabled states. export type CellView = "none" | "grid" | "chart" @@ -1825,19 +1796,8 @@ export const buildAppliedNotebookState = ( nextMaximizedCellId = null } - // Apply is a PUT: an omitted auto_refresh clears any prior override. Cells - // becoming charts (new, or run→draw) are stamped born-live, mirroring the - // editor's draw switch — but a cell that already was a chart keeps its - // cleared state, so an agent echoing read state cannot flip an inheriting - // chart back to Auto. - const stampedCells = cells.map((cell) => { - if (prevById.get(cell.id)?.mode === "draw") return cell - const stamp = chartAutoRefreshStamp(cell, nextSettings.autoRefreshDefault) - return "autoRefresh" in stamp ? { ...cell, ...stamp } : cell - }) - return { - cells: stampedCells, + cells, settings: nextSettings, maximizedCellId: nextMaximizedCellId, diff, diff --git a/src/store/notebook.ts b/src/store/notebook.ts index 2e0f1695c..7563cf1ab 100644 --- a/src/store/notebook.ts +++ b/src/store/notebook.ts @@ -192,46 +192,22 @@ export const migrateLegacyCellNames = ( ? { ...state, cells: state.cells.map(migrateCellName) } : state -// Nothing polls unless the cell or the notebook says so — but charts are born -// live: entering draw mode stamps an explicit Auto onto the cell. A stored -// notebook default (even Off) is the user's notebook-wide intent, so the -// stamp yields to it and the chart inherits instead. -export const chartAutoRefreshStamp = ( - cell: Pick, - autoRefreshDefault: AutoRefresh | undefined, -): Partial => - cell.mode === "draw" && - cell.autoRefresh === undefined && - autoRefreshDefault === undefined - ? { autoRefresh: true } - : {} - -// A stamped chart carries the same `autoRefresh: true` a user override would; -// this tells the two apart so born-live charts don't read as user overrides. -export const isChartAutoRefreshStamp = ( - cell: Pick, - autoRefreshDefault: AutoRefresh | undefined, -): boolean => - cell.mode === "draw" && - cell.autoRefresh === true && - autoRefreshDefault === undefined - // Charts drawn before the uniform-Off fallback polled through an implicit -// per-view Auto. The stamp makes that liveness explicit so they keep polling; -// grids never polled, and stay off. No notebook default is ever synthesized. -// The marker makes the stamp one-time: on a migrated view an undefined -// autoRefresh is a deliberate "inherit", and re-stamping it would silently -// revert a cleared chart to Auto. +// per-view Auto. The migration writes that liveness as an explicit Auto +// override so they keep polling; grids never polled, and stay off. No +// notebook default is ever synthesized. The marker makes the write one-time: +// on a migrated view an undefined autoRefresh is a deliberate "inherit". export const migrateImplicitChartAutoRefresh = ( state: NotebookViewState, ): NotebookViewState => { if (state.settings?.autoRefreshMigrated) return state - const autoRefreshDefault = state.settings?.autoRefreshDefault - const needsStamp = (cell: NotebookCell) => - Object.keys(chartAutoRefreshStamp(cell, autoRefreshDefault)).length > 0 - const cells = state.cells.some(needsStamp) + const legacyLiveChart = (cell: NotebookCell) => + cell.mode === "draw" && + cell.autoRefresh === undefined && + state.settings?.autoRefreshDefault === undefined + const cells = state.cells.some(legacyLiveChart) ? state.cells.map((cell) => - needsStamp(cell) ? { ...cell, autoRefresh: true as const } : cell, + legacyLiveChart(cell) ? { ...cell, autoRefresh: true as const } : cell, ) : state.cells return { diff --git a/src/utils/ai/notebookSnapshot.ts b/src/utils/ai/notebookSnapshot.ts index b01a92b59..48f2f69ed 100644 --- a/src/utils/ai/notebookSnapshot.ts +++ b/src/utils/ai/notebookSnapshot.ts @@ -227,8 +227,7 @@ export const buildSnapshot = async ( buildCell(c, gridByCellId, layoutMode, refreshState), ), } - // Absence stays observable: with no configured default, charts poll on Auto - // and grids do not poll at all. + // Absence stays observable: with no configured default, nothing polls. if (settings.autoRefreshDefault !== undefined) { out.auto_refresh_default = settings.autoRefreshDefault } diff --git a/src/utils/ai/shared.notebookTools.test.ts b/src/utils/ai/shared.notebookTools.test.ts index c1d05fabc..250b9ea8b 100644 --- a/src/utils/ai/shared.notebookTools.test.ts +++ b/src/utils/ai/shared.notebookTools.test.ts @@ -639,7 +639,7 @@ describe("dispatchTool — notebook tools (happy path)", () => { expect(state.parts.settings.autoRefreshDefault).toBeUndefined() }) - it("set_cell_mode draw stamps an explicit Auto so the chart is born polling", async () => { + it("set_cell_mode draw never writes auto_refresh — the chart inherits", async () => { // Given a run cell in a notebook with no auto-refresh default const { state } = mountLive(1, [cell("c", "SELECT 1")]) @@ -657,37 +657,12 @@ describe("dispatchTool — notebook tools (happy path)", () => { }), ) - // Then the chart carries the stamp as an ordinary per-cell value - expect(cellById(state, "c")?.mode).toBe("draw") - expect(cellById(state, "c")?.autoRefresh).toBe(true) - }) - - it("set_cell_mode draw yields to a stored notebook default instead of stamping", async () => { - // Given a notebook whose owner chose a notebook-wide cadence - const { state } = mountLive(1, [cell("c", "SELECT 1")], { - settings: { autoRefreshDefault: "30s" }, - }) - - // When the agent switches the cell to draw mode - await dispatchTool( - "set_cell_mode", - { buffer_id: 1, cell_id: "c", mode: "draw" }, - makeClient(), - noopStatus, - { grantSchemaAccess: true, read: true, write: false }, - vi.fn().mockResolvedValue({ - query: "SELECT 1", - columns: [{ name: "1", type: "INT" }], - timestamp: 0, - }), - ) - - // Then no stamp lands — the chart inherits the notebook default + // Then the chart stores no per-cell value expect(cellById(state, "c")?.mode).toBe("draw") expect(cellById(state, "c")?.autoRefresh).toBeUndefined() }) - it("apply_notebook_state stamps draw cells sent without auto_refresh", async () => { + it("apply_notebook_state never synthesizes auto_refresh for draw cells", async () => { // Given a notebook with no auto-refresh default const { state } = mountLive(1, [cell("a", "SELECT 1")]) @@ -712,17 +687,17 @@ describe("dispatchTool — notebook tools (happy path)", () => { noopStatus, ) - // Then the chart is born polling and the grid cell stays unstamped + // Then no cell stores a per-cell value — both inherit const chart = state.parts.cells.find((c) => c.mode === "draw") - expect(chart?.autoRefresh).toBe(true) + expect(chart?.autoRefresh).toBeUndefined() expect(cellById(state, "a")?.autoRefresh).toBeUndefined() }) - it("apply_notebook_state does not stamp when the same apply sets a default", async () => { - // Given an apply that configures the notebook default and a chart together + it("apply_notebook_state stores auto_refresh_default alongside cells", async () => { + // Given a notebook with no auto-refresh default const { state } = mountLive(1, [cell("a", "SELECT 1")]) - // When the apply carries auto_refresh_default alongside the draw cell + // When the apply carries auto_refresh_default alongside a draw cell await dispatchTool( "apply_notebook_state", { @@ -744,7 +719,7 @@ describe("dispatchTool — notebook tools (happy path)", () => { noopStatus, ) - // Then the chart inherits the default instead of carrying a stamp + // Then the default lands and the chart inherits it const chart = state.parts.cells.find((c) => c.mode === "draw") expect(chart?.autoRefresh).toBeUndefined() expect(state.parts.settings.autoRefreshDefault).toBe("30s") diff --git a/src/utils/notebooks/notebookController/notebookTransitions.ts b/src/utils/notebooks/notebookController/notebookTransitions.ts index e38ba2152..710f684ca 100644 --- a/src/utils/notebooks/notebookController/notebookTransitions.ts +++ b/src/utils/notebooks/notebookController/notebookTransitions.ts @@ -1,5 +1,4 @@ import { - chartAutoRefreshStamp, MAX_NOTEBOOK_CELLS, type AutoRefresh, type CellMode, @@ -322,12 +321,6 @@ export const setCellModeTransition = ( cells: patchCellIn(parts.cells, cellId, { mode, ...cellModeChangePatch(cell, mode), - ...(entersDraw - ? chartAutoRefreshStamp( - { mode, autoRefresh: cell.autoRefresh }, - parts.settings.autoRefreshDefault, - ) - : {}), }), }, result: undefined, From 2a4ef5becaf46f88a98469b5d0e53060163b79d3 Mon Sep 17 00:00:00 2001 From: emrberk Date: Wed, 12 Aug 2026 15:53:42 +0300 Subject: [PATCH 15/17] =?UTF-8?q?keep=20reveal=20freshness=20monotonic=20?= =?UTF-8?q?=E2=80=94=20hide/reveal=20and=20refocus=20honor=20the=20interva?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensureData/ensureGridData reset lastFetchedAt to the frame timestamp, which never advances on grid ticks (it is the viewport token) and stays behind on unchanged chart data. Every reveal or tab refocus therefore looked stale and refetched immediately, ignoring the configured interval. Take the max of the settled stamp and the frame timestamp so freshness never regresses; the explicit zero resets on force-fetch paths keep working. Co-Authored-By: Claude Fable 5 --- .../cellRefresh/cellRefreshEngine.test.ts | 22 +++++++++++++++++++ .../Notebook/cellRefresh/cellRefreshEngine.ts | 6 +++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts index 52ccf0290..02cd45532 100644 --- a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts +++ b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts @@ -2164,6 +2164,28 @@ describe("CellRefreshEngine", () => { expect(written?.timestamp).toBe(0) }) + it("skips the reveal catch-up when a polling grid ticked recently", async () => { + // Given a polling grid that completed a round moments ago + const cell = gridCell("g1", "select 1", ["select 1"], "1m") + syncOnScreen([cell]) + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + + // When it is hidden and revealed well within its interval + engine.setVisible("g1", false) + await vi.advanceTimersByTimeAsync(5000) + engine.setVisible("g1", true) + await flushAsync() + + // Then there is no immediate refetch — the stale frame timestamp must + // not regress the freshness the settled round stamped + expect(deps.executeSingle).toHaveBeenCalledTimes(1) + + // And the next tick still arrives on the interval + await vi.advanceTimersByTimeAsync(60_000) + expect(deps.executeSingle).toHaveBeenCalledTimes(2) + }) + it("skips the commit on identical rows, while the fetch time advances", async () => { // Given a polling grid whose first tick swapped fresh rows in const cell = gridCell("g1", "select 1", ["select 1"], "1s") diff --git a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts index 310efb87b..4b42ac77d 100644 --- a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts +++ b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts @@ -828,7 +828,7 @@ export class CellRefreshEngine { ) { this.setState(entry, { settledKey: queriesKey }) this.deriveChartSlotErrors(entry) - entry.lastFetchedAt = chartResult.timestamp + entry.lastFetchedAt = Math.max(entry.lastFetchedAt, chartResult.timestamp) this.updatePoll(entry) return } @@ -852,7 +852,9 @@ export class CellRefreshEngine { const result = this.getDeps().getCellResult(entry.cellId) if (result != null) { this.setState(entry, { settledKey: entry.state.queriesKey }) - entry.lastFetchedAt = result.timestamp + // The frame timestamp never advances on grid ticks (it is the viewport + // token) — a reveal must not regress the freshness finishRound stamped. + entry.lastFetchedAt = Math.max(entry.lastFetchedAt, result.timestamp) this.updatePoll(entry) return } From 22ed13063211941152c2b0c8aab66131a3d30e83 Mon Sep 17 00:00:00 2001 From: emrberk Date: Wed, 12 Aug 2026 17:35:48 +0300 Subject: [PATCH 16/17] persist grid frames on error-set change, throttle repeats A round that left failures re-armed the snapshot throttle bypass every tick, so a persistently failing statement put a full frame to IndexedDB once per second, indefinitely. The bypass now fires when the slot-error set CHANGED since the last successful write (first failure, new message, recovery) and on manual refresh; a repeating failure follows the 10s throttle like any healthy tick. Unchanged frames still re-persist once per window so a reload shows a current savedAt. The error-set signature commits only on a successful write, so a rejected write of a changed set retries on the next round. Co-Authored-By: Claude Fable 5 --- .../cellRefresh/cellRefreshEngine.test.ts | 178 ++++++++++++++++++ .../Notebook/cellRefresh/cellRefreshEngine.ts | 42 ++++- 2 files changed, 210 insertions(+), 10 deletions(-) diff --git a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts index 02cd45532..92b2575c5 100644 --- a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts +++ b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts @@ -2389,6 +2389,184 @@ describe("CellRefreshEngine", () => { expect(persistCellSnapshot).toHaveBeenCalledTimes(2) }) + it("a repeating failure writes once per throttle window, never per tick", async () => { + // Given a polling grid whose second statement fails every tick + deps.executeSingle.mockImplementation((sql: string) => + sql === "select 2" + ? Promise.resolve({ + type: "error", + query: sql, + columns: [], + dataset: [], + count: 0, + error: "boom", + }) + : Promise.resolve(dqlResult(sql)), + ) + const cell = gridCell( + "g1", + "select 1; select 2", + ["select 1", "select 2"], + "1s", + ) + syncOnScreen([cell]) + await flushAsync() + // the first failing round flushed the frame with its error immediately + expect(persistCellSnapshot).toHaveBeenCalledTimes(1) + + // When the same failure repeats across many ticks + await vi.advanceTimersByTimeAsync(25_000) + + // Then writes land once per window — savedAt stays fresh with no + // per-tick churn + expect(persistCellSnapshot).toHaveBeenCalledTimes(3) + + // And a recovery persists the clean frame immediately + deps.executeSingle.mockImplementation((sql: string) => + Promise.resolve(dqlResult(sql)), + ) + await vi.advanceTimersByTimeAsync(1500) + expect(persistCellSnapshot).toHaveBeenCalledTimes(4) + const recovered = vi.mocked(persistCellSnapshot).mock.calls[3][0] + expect(recovered.refreshErrors).toBeUndefined() + }) + + it("throttles rows-changing ticks under a repeating failure — the error rides along each window", async () => { + // Given a polling grid whose data moves every tick while one statement + // always fails + let tick = 0 + deps.executeSingle.mockImplementation((sql: string) => + sql === "select 2" + ? Promise.resolve({ + type: "error", + query: sql, + columns: [], + dataset: [], + count: 0, + error: "boom", + }) + : Promise.resolve({ + type: "dql" as const, + query: sql, + columns: [{ name: "x", type: "INT" }], + dataset: [[100 + ++tick]], + count: 1, + }), + ) + const cell = gridCell( + "g1", + "select 1; select 2", + ["select 1", "select 2"], + "1s", + ) + syncOnScreen([cell]) + await flushAsync() + // the first failing round persisted immediately (error set changed) + expect(persistCellSnapshot).toHaveBeenCalledTimes(1) + + // When rows keep changing under the same failure + await vi.advanceTimersByTimeAsync(4000) + + // Then the intermediate ticks stay throttled — no write per second + expect(persistCellSnapshot).toHaveBeenCalledTimes(1) + + // And the reopened window flushes the latest frame, error included + await vi.advanceTimersByTimeAsync(7000) + expect(persistCellSnapshot).toHaveBeenCalledTimes(2) + const flushed = vi.mocked(persistCellSnapshot).mock.calls[1][0] + expect(flushed.refreshErrors).toEqual([ + { statementKey: keyOf("select 2"), message: "boom" }, + ]) + }) + + it("a changed error message bypasses the throttle", async () => { + // Given a polling grid whose statement fails with one message + deps.executeSingle.mockImplementation((sql: string) => + Promise.resolve({ + type: "error", + query: sql, + columns: [], + dataset: [], + count: 0, + error: "boom", + }), + ) + const cell = gridCell("g1", "select 1", ["select 1"], "1s") + syncOnScreen([cell]) + await flushAsync() + expect(persistCellSnapshot).toHaveBeenCalledTimes(1) + + // When the failure message changes inside the throttle window + deps.executeSingle.mockImplementation((sql: string) => + Promise.resolve({ + type: "error", + query: sql, + columns: [], + dataset: [], + count: 0, + error: "boom2", + }), + ) + await vi.advanceTimersByTimeAsync(1500) + + // Then the new error state writes straight away + expect(persistCellSnapshot).toHaveBeenCalledTimes(2) + const rewritten = vi.mocked(persistCellSnapshot).mock.calls[1][0] + expect(rewritten.refreshErrors).toEqual([ + { statementKey: keyOf("select 1"), message: "boom2" }, + ]) + }) + + it("retries a failed error-frame write immediately on the next round", async () => { + // Given the disk rejects the first write of a failing round + vi.mocked(persistCellSnapshot).mockResolvedValueOnce(false) + deps.executeSingle.mockImplementation((sql: string) => + sql === "select 2" + ? Promise.resolve({ + type: "error", + query: sql, + columns: [], + dataset: [], + count: 0, + error: "boom", + }) + : Promise.resolve(dqlResult(sql)), + ) + const cell = gridCell( + "g1", + "select 1; select 2", + ["select 1", "select 2"], + "1s", + ) + syncOnScreen([cell]) + await flushAsync() + expect(persistCellSnapshot).toHaveBeenCalledTimes(1) + + // When the next round settles with the error still unsynced to disk + await vi.advanceTimersByTimeAsync(1500) + + // Then the write retries immediately, and once it lands the repeats + // fall back to the throttle + expect(persistCellSnapshot).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(2000) + expect(persistCellSnapshot).toHaveBeenCalledTimes(2) + }) + + it("an unchanged polling frame still re-persists once per window, keeping savedAt fresh", async () => { + // Given a polling grid whose data never changes after the first swap + const cell = gridCell("g1", "select 1", ["select 1"], "1s") + syncOnScreen([cell]) + await flushAsync() + expect(persistCellSnapshot).toHaveBeenCalledTimes(1) + + // When ticks continue long past the 10s throttle window + await vi.advanceTimersByTimeAsync(25_000) + + // Then one write per window keeps the reload timestamp current — a + // reload must not show minutes-old data under a live poller + expect(persistCellSnapshot).toHaveBeenCalledTimes(3) + }) + it("throttles automatic ticks — a lost tick frame is regenerated by the next one", async () => { // Given a grid polling every second const cell = gridCell("g1", "select 1", ["select 1"], "1s") diff --git a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts index 4b42ac77d..532965e93 100644 --- a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts +++ b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts @@ -197,6 +197,15 @@ const sameEntryCells = ( previous[index].kind === kind, ) +const slotErrorsSignature = ( + slotErrors: ReadonlyMap, +): string => + JSON.stringify( + [...slotErrors.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), + ) + +const NO_ERRORS_SIG = slotErrorsSignature(new Map()) + type Entry = { kind: CellEntryKind cellId: string @@ -222,6 +231,7 @@ type Entry = { resultLoadUnsubscribe: (() => void) | null lastSnapshotAt: number persistedResults: WeakMap + lastPersistedErrorsSig: string | null lastClearedSqlHash: string | null pendingSnapshot: { results: SingleQueryResult[]; durationMs: number } | null snapshotTimer: ReturnType | null @@ -539,6 +549,9 @@ export class CellRefreshEngine { if (!entry) return this.dropPendingSnapshot(entry) entry.lastSnapshotAt = Date.now() + // The run's own record replaced the disk copy, and it carries no + // refreshErrors. + entry.lastPersistedErrorsSig = NO_ERRORS_SIG // The refresh stamps describe rounds the run just superseded: leaving // them would show the old refresh time under the run's rows. Cleared, the // status line falls back to the frame's own run timestamp. @@ -563,6 +576,9 @@ export class CellRefreshEngine { if (!entry) return this.dropPendingSnapshot(entry) entry.lastSnapshotAt = Date.now() + // The rerun's own record replaced the disk copy, and it carries no + // refreshErrors. + entry.lastPersistedErrorsSig = NO_ERRORS_SIG this.clearSlotError(entry, statementKey) this.clearSlotFetchStamp(entry, statementKey) if (entry.state.slotErrors.size > 0) this.persistGridFrame(entry, true) @@ -637,6 +653,7 @@ export class CellRefreshEngine { resultLoadUnsubscribe: null, lastSnapshotAt: 0, persistedResults: new WeakMap(), + lastPersistedErrorsSig: null, lastClearedSqlHash: null, pendingSnapshot: null, snapshotTimer: null, @@ -1201,7 +1218,6 @@ export class CellRefreshEngine { launchPatch.slotFetching = slotFetching } this.setState(entry, launchPatch) - let hadFailure = invalidSlots.length > 0 await Promise.all( launchSlots.map(async ({ key, index }) => { if (round.signal.aborted) return @@ -1219,7 +1235,6 @@ export class CellRefreshEngine { ) if (slotAbort.signal.aborted || round.signal.aborted) return if (exec.type === "error") { - hadFailure = true this.setSlotError(entry, key, exec.error ?? "Query failed") } else { // The fetch time always advances — the status line shows the @@ -1235,7 +1250,6 @@ export class CellRefreshEngine { } } catch (e) { if (slotAbort.signal.aborted || round.signal.aborted) return - hadFailure = true this.setSlotError(entry, key, errorMessage(e)) } finally { // A superseded round's late settle must not clobber the slot the @@ -1250,7 +1264,7 @@ export class CellRefreshEngine { ) if (round.signal.aborted) return this.setState(entry, { settledKey: queriesKey }) - this.persistGridFrame(entry, hadFailure || manual) + this.persistGridFrame(entry, manual) } finally { this.finishRound(entry, round) } @@ -1321,16 +1335,22 @@ export class CellRefreshEngine { // The snapshot write unit is always the WHOLE visible frame — never an // error alone. // - // `immediate` bypasses the throttle. It covers every frame whose loss the - // user would notice: a round that left failures, and any round the user - // asked for by hand. Only automatic ticks stay throttled, and losing one is - // self-correcting — the next tick regenerates it. The pagehide flush is a + // Every settled round queues a write through the 10s throttle — an + // unchanged frame still re-persists once per window, so a reload's savedAt + // stays current instead of showing minutes-old data under a live poller. + // The throttle is bypassed only when the error set CHANGED (first failure, + // new message, recovery) — that state must survive an immediate reload — + // and when the user asked by hand. A repeating failure follows the + // throttle like any healthy tick; losing a throttled tick is + // self-correcting — the next one regenerates it. The pagehide flush is a // best-effort backstop, not a guarantee: an IndexedDB write started during // teardown is routinely dropped, so nothing durable may depend on it. - private persistGridFrame(entry: Entry, immediate: boolean) { + private persistGridFrame(entry: Entry, manual: boolean) { const current = this.getDeps().getCellResult(entry.cellId) if (!current || current.results.length === 0) return - if (immediate) entry.lastSnapshotAt = 0 + const errorsSig = slotErrorsSignature(entry.state.slotErrors) + if (manual || errorsSig !== entry.lastPersistedErrorsSig) + entry.lastSnapshotAt = 0 this.queueSnapshot(entry, current.results, 0) } @@ -1478,6 +1498,7 @@ export class CellRefreshEngine { ): Promise { this.dropPendingSnapshot(entry) const persistedSqlHash = sqlHash(entry.sql) + const persistedErrorsSig = slotErrorsSignature(entry.state.slotErrors) const current = this.getDeps().getCellResult(entry.cellId) const failedCount = results.filter((r) => r.type === "error").length const script = @@ -1513,6 +1534,7 @@ export class CellRefreshEngine { }).then((saved) => { if (!saved) return entry.persistedResults.set(results, persistedSqlHash) + entry.lastPersistedErrorsSig = persistedErrorsSig entry.lastClearedSqlHash = null this.getDeps().onSnapshotPersisted(entry.cellId, results) }) From 57ba6a243a7b8237154450724bcfe738b892b76c Mon Sep 17 00:00:00 2001 From: emrberk Date: Wed, 12 Aug 2026 17:36:12 +0300 Subject: [PATCH 17/17] resolve the single-run target from the statement frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the editor unmounted (view-maximized cell), Cmd+Enter fell back to results[activeResultIndex].query. Selecting a "Not run" tab moves activeStatementKey but cannot move that index, so the run executed a stale statement — possibly a write. The target now resolves from the same frame the tabs render: active statement key first, legacy index fallback for keyless snapshots, positional fallback for selection fragments. A slot without a result still resolves to its own SQL, so a single run never expands into running every statement. Co-Authored-By: Claude Fable 5 --- .../Notebook/cells/useCellRunActions.ts | 11 +++-- .../Editor/Notebook/notebookUtils.test.ts | 47 +++++++++++++++++++ src/scenes/Editor/Notebook/notebookUtils.ts | 13 +++++ 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/scenes/Editor/Notebook/cells/useCellRunActions.ts b/src/scenes/Editor/Notebook/cells/useCellRunActions.ts index edf7b72d7..53fc2985f 100644 --- a/src/scenes/Editor/Notebook/cells/useCellRunActions.ts +++ b/src/scenes/Editor/Notebook/cells/useCellRunActions.ts @@ -6,7 +6,7 @@ import { useCellRefresh } from "../cellRefresh/CellRefreshContext" import { useLocalStorage } from "../../../../providers/LocalStorageProvider" import { useValidateWithGlobals } from "../globals/useValidateWithGlobals" import { getQueryFromCursor, normalizeQueryText } from "../../Monaco/utils" -import { resolveRunAction } from "../notebookUtils" +import { resolveActiveStatementSql, resolveRunAction } from "../notebookUtils" import { emitUserAction, signalUserEdit, @@ -178,10 +178,10 @@ export const useCellRunActions = ({ // gesture can unmount the editor, and reading it afterwards loses it. const cursorQuery = ed ? getQueryFromCursor(ed)?.query : undefined if (ed && (await tryRunSelection())) return - // Cursor first; otherwise reuse the active result tab's query so a single - // run never silently expands into running every statement. - const activeQuery = - cell.result?.results[cell.result.activeResultIndex]?.query + // Cursor first; otherwise the active tab's statement — resolved from the + // same frame the tabs render, so a "Not run" tab runs its own SQL and a + // single run never silently expands into running every statement. + const activeQuery = resolveActiveStatementSql(cell.value, cell.result) const sql = cursorQuery ?? activeQuery if (!sql?.trim()) { await handleRunAll() @@ -196,6 +196,7 @@ export const useCellRunActions = ({ emitRanEvent(createRunStatus(priorResult, freshResult, ok)) }, [ cell.id, + cell.value, cell.result, runCell, tryRunSelection, diff --git a/src/scenes/Editor/Notebook/notebookUtils.test.ts b/src/scenes/Editor/Notebook/notebookUtils.test.ts index 8e6da93d7..05217df21 100644 --- a/src/scenes/Editor/Notebook/notebookUtils.test.ts +++ b/src/scenes/Editor/Notebook/notebookUtils.test.ts @@ -50,6 +50,7 @@ import { reconcileResultsForStatements, derivePositionalFrame, deriveStatementFrame, + resolveActiveStatementSql, statementKeysFor, removeCell, resolveRunCompletion, @@ -3810,3 +3811,49 @@ describe("derivePositionalFrame — orphan results (selection runs)", () => { expect(derivePositionalFrame(resultOf([]))).toBeNull() }) }) + +describe("resolveActiveStatementSql — the single-run target", () => { + it("resolves a selected 'Not run' tab to its own SQL, not the stale result index", () => { + // Given a two-result frame whose active tab is an appended, never-run + // statement — activeResultIndex still points at the first result + const result = resultOf([dqlResult("SELECT 1"), dqlResult("SELECT 2")], { + activeResultIndex: 0, + activeStatementKey: statementKeysFor([ + "SELECT 1", + "SELECT 2", + "SELECT 3", + ])[2], + }) + // When the single-run target is resolved with the editor unavailable + const sql = resolveActiveStatementSql( + "SELECT 1; SELECT 2; SELECT 3", + result, + ) + // Then the selected statement runs — never the stale index's query + expect(sql).toBe("SELECT 3") + }) + + it("falls back to the result index for a legacy snapshot without a key", () => { + // Given an old record that only carries the active index + const result = resultOf([dqlResult("SELECT 1"), dqlResult("SELECT 2")], { + activeResultIndex: 1, + }) + // Then the index's own statement resolves + expect(resolveActiveStatementSql("SELECT 1; SELECT 2", result)).toBe( + "SELECT 2", + ) + }) + + it("resolves a selection-fragment frame positionally", () => { + // Given a fragment result no editor statement claims + const result = resultOf([dqlResult("SELECT 99")]) + // Then the fragment's own query resolves + expect(resolveActiveStatementSql("SELECT 1; SELECT 2", result)).toBe( + "SELECT 99", + ) + }) + + it("returns undefined without a result, so the caller can fall back", () => { + expect(resolveActiveStatementSql("SELECT 1", null)).toBeUndefined() + }) +}) diff --git a/src/scenes/Editor/Notebook/notebookUtils.ts b/src/scenes/Editor/Notebook/notebookUtils.ts index 5005be8b8..18cec3bcc 100644 --- a/src/scenes/Editor/Notebook/notebookUtils.ts +++ b/src/scenes/Editor/Notebook/notebookUtils.ts @@ -918,6 +918,19 @@ export const derivePositionalFrame = ( } } +// The single-run target mirrors the tab the bottom slot renders — the active +// slot carries its statement even before it has run, so a "Not run" tab +// resolves to its own SQL, never to a stale result index. +export const resolveActiveStatementSql = ( + value: string, + result: CellResult | null | undefined, +): string | undefined => { + const frame = + deriveStatementFrame(getQueriesFromText(value), result) ?? + derivePositionalFrame(result) + return frame?.slots[frame.activeSlotIndex]?.sql +} + export const cloneNotebookViewStateWithCellIdMap = ( source: NotebookViewState, newId: () => string = generateId,