Files
supabase/apps/studio/state/sql-editor/sql-editor-session-state.test.ts
T
CharisandJoshen Lim d153bab849 refactor(studio): extract SQL editor session store from god store (#47349)
## What

PR 6 of the SQL editor state re-layering stack. Moves ephemeral,
never-persisted SQL editor state out of the snippet/folder "god store".

**Session store** — `state/sql-editor/sql-editor-session-state.ts` holds
per-snippet, read-by-many session state:
- query `results`
- `explainResults`
- the row `limit`

…with their mutators (`addResult`/`addResultError`/`resetResult`,
`addExplainResult`/`addExplainResultError`/`resetExplainResult`,
`resetResults`, `setLimit`). `removeSnippet` drops a snippet's session
entries via `clearForSnippet(id)`.

**Diff-request slice** — `state/sql-editor/sql-editor-diff-request.ts`.
The Assistant's "Insert code" / "Replace code" diff is *not* per-snippet
session state: it's a transient, fire-and-forget command produced
outside the editor (e.g. query blocks / assistant) and consumed exactly
once by whichever editor is active. It's modeled as a consume-once
request (`requestDiff` / `consumeDiffRequest`) rather than durable state
— the editor drains it on apply, so a stale diff can't leak into a later
editor or session. (Previously this was `diffContent` in the god store:
never cleared and triggered by object-reference identity.)

Consumers read session state from `useSqlEditorSessionSnapshot` and the
diff channel from `useSqlEditorDiffRequestSnapshot`, keeping
`useSqlEditorV2StateSnapshot` only for snippets/folders.

### Why not the TanStack Query cache for results/explain?

Editor execution is a **mutation**, not a keyed query — `mutation.data`
is per-hook-instance and not keyed by snippet id, and there's no caching
value to capture (re-running SQL must return *fresh* data, never a
cached result). `EXPLAIN ANALYZE` actually executes the statement, so a
declarative/auto-refetching `useQuery` is semantically wrong.
Results/explain are imperative mutation outputs, scoped to the session,
read by several decoupled consumers keyed by snippet id — exactly what a
small in-memory keyed store models honestly.

## Consumers migrated

- `SQLEditor.tsx` — results/explain/limit reads +
`addResult`/`addResultError`/`addExplainResult`/`addExplainResultError`/`setLimit`;
diff-apply effect now drains a consume-once request
- `UtilityPanel.tsx`, `UtilityTabResults.tsx`, `UtilityTabExplain.tsx`,
`UtilityActions.tsx`
- `QueryBlock/EditQueryButton.tsx` — produces via `requestDiff`

## Notes

- Result/explain types are kept verbatim from the god store
(pre-existing `any` row/error types come along unchanged; tightening
them is out of scope for this move).
- `ref()` on result rows is preserved to avoid Valtio proxying large row
sets.

## Tests

- `sql-editor-session-state.test.ts` — result/explain mutators,
`resetResults`, `clearForSnippet`, `limit`
- `sql-editor-diff-request.test.ts` — `requestDiff`,
`consumeDiffRequest` (drain + queue-of-one)

Validation:
- `pnpm --filter studio typecheck` ✅
- `pnpm exec vitest --run state/sql-editor/` ✅ (110 passed)
- lint ✅ (no new errors)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* SQL editor query results, EXPLAIN output, and the “Limit results to”
setting now persist more reliably across a session.
* AI-assisted SQL insert/replace actions now use a pending diff workflow
to apply updates more consistently.

* **Bug Fixes**
* Results/EXPLAIN rendering and downloads stay in sync with the latest
executed data.
* Switching databases/snippets now clears the correct temporary results.
* Diff application is more resilient when an editor is still loading,
including empty-vs-non-empty editor cases.

* **Tests**
  * Added coverage for the session and diff-request state logic.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-06-30 11:24:48 -04:00

95 lines
3.4 KiB
TypeScript

import { snapshot } from 'valtio'
import { beforeEach, describe, expect, it } from 'vitest'
import { sqlEditorSessionState } from './sql-editor-session-state'
// The session store is a module singleton, so reset its mutable fields before
// each test to keep cases independent.
beforeEach(() => {
sqlEditorSessionState.results = {}
sqlEditorSessionState.explainResults = {}
sqlEditorSessionState.limit = 100
})
describe('sqlEditorSessionState', () => {
describe('results', () => {
it('addResult stores rows (and optional autoLimit) keyed by snippet id', () => {
sqlEditorSessionState.addResult('a', [{ x: 1 }], 50)
const result = sqlEditorSessionState.results['a']?.[0]
expect(result?.rows).toEqual([{ x: 1 }])
expect(result?.autoLimit).toBe(50)
expect(result?.error).toBeUndefined()
})
it('addResultError stores the error with empty rows', () => {
sqlEditorSessionState.addResultError('a', { message: 'boom' }, 25)
const result = sqlEditorSessionState.results['a']?.[0]
expect(result?.rows).toEqual([])
expect(result?.error).toEqual({ message: 'boom' })
expect(result?.autoLimit).toBe(25)
})
it('resetResult clears the rows for a snippet', () => {
sqlEditorSessionState.addResult('a', [{ x: 1 }])
sqlEditorSessionState.resetResult('a')
expect(sqlEditorSessionState.results['a']).toEqual([])
})
})
describe('explain results', () => {
it('addExplainResult stores rows keyed by snippet id', () => {
const rows = [{ 'QUERY PLAN': 'Seq Scan' }]
sqlEditorSessionState.addExplainResult('a', rows)
expect(sqlEditorSessionState.explainResults['a']?.rows).toEqual(rows)
expect(sqlEditorSessionState.explainResults['a']?.error).toBeUndefined()
})
it('addExplainResultError stores the error with empty rows', () => {
sqlEditorSessionState.addExplainResultError('a', { message: 'bad plan' })
expect(sqlEditorSessionState.explainResults['a']?.rows).toEqual([])
expect(sqlEditorSessionState.explainResults['a']?.error).toEqual({ message: 'bad plan' })
})
})
describe('resetResults', () => {
it('clears both the query result and the explain result for a snippet', () => {
sqlEditorSessionState.addResult('a', [{ x: 1 }])
sqlEditorSessionState.addExplainResult('a', [{ 'QUERY PLAN': 'Seq Scan' }])
sqlEditorSessionState.resetResults('a')
expect(sqlEditorSessionState.results['a']).toEqual([])
expect(sqlEditorSessionState.explainResults['a']).toEqual({ rows: [] })
})
})
describe('clearForSnippet', () => {
it('deletes both results and explain results for a snippet, leaving others intact', () => {
sqlEditorSessionState.addResult('a', [{ x: 1 }])
sqlEditorSessionState.addExplainResult('a', [{ 'QUERY PLAN': 'Seq Scan' }])
sqlEditorSessionState.addResult('b', [{ y: 2 }])
sqlEditorSessionState.clearForSnippet('a')
expect(sqlEditorSessionState.results['a']).toBeUndefined()
expect(sqlEditorSessionState.explainResults['a']).toBeUndefined()
expect(sqlEditorSessionState.results['b']?.[0]?.rows).toEqual([{ y: 2 }])
})
})
describe('limit', () => {
it('defaults to 100 and is updated by setLimit', () => {
expect(snapshot(sqlEditorSessionState).limit).toBe(100)
sqlEditorSessionState.setLimit(500)
expect(snapshot(sqlEditorSessionState).limit).toBe(500)
})
})
})