mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
refactor(studio): relocate SQL editor store into state/sql-editor/ with facade (1/9) (#47203)
## What PR 1 of a stacked refactor that re-layers the SQL editor snippet state (`apps/studio/state/sql-editor-v2.ts`). This first PR is a **pure structural move with zero behavior change** — no consumer files are touched. - Relocates the Valtio store body into `apps/studio/state/sql-editor/sql-editor-state.ts` - Extracts the type declarations into `apps/studio/state/sql-editor/types.ts` - Adds `apps/studio/state/sql-editor/index.ts` as the public surface - Keeps the old `apps/studio/state/sql-editor-v2.ts` path as a thin re-export **facade**, so all existing importers keep working unchanged ## How to read the diff `sql-editor/sql-editor-state.ts` (~507 lines) is the **verbatim relocation** of the former `sql-editor-v2.ts` body — not new code. Git does not show it as a rename because the old path is intentionally retained as the facade. The only genuinely new lines are `types.ts` (20), `index.ts` (8), and the facade itself (8). ## Why The store has accreted four tangled responsibilities (snippet/folder CRUD, query results, persistence, Assistant diff). The stack incrementally splits these into pure rules, a persistent store, a session store, and an injectable save mechanism whose trigger is a swappable policy (setting up a future auto→manual save migration). Each PR stays ≤300–400 non-test lines and behavior-preserving. ## Verification - `pnpm --filter studio typecheck` passes (only pre-existing unrelated module-resolution errors remain). - Lint passes (no new errors). - No consumer imports changed. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Restructured SQL editor state management into a modular architecture with improved separation of concerns and enhanced code organization. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
acd9ddabc5
commit
2b24065c7b
4 files changed
+543
-525
No files matched your search
@@ -1,525 +1,8 @@
|
||||
import { untrustedSql } from '@supabase/pg-meta'
|
||||
import { debounce, memoize } from 'lodash'
|
||||
import { useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio'
|
||||
import { devtools, proxyMap } from 'valtio/utils'
|
||||
|
||||
import type { QueryPlanRow } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.types'
|
||||
import { DiffType } from '@/components/interfaces/SQLEditor/SQLEditor.types'
|
||||
import { upsertContent, UpsertContentPayload } from '@/data/content/content-upsert-mutation'
|
||||
import { contentKeys } from '@/data/content/keys'
|
||||
import { createSQLSnippetFolder } from '@/data/content/sql-folder-create-mutation'
|
||||
import { updateSQLSnippetFolder } from '@/data/content/sql-folder-update-mutation'
|
||||
import { Snippet, SnippetFolder } from '@/data/content/sql-folders-query'
|
||||
import { getQueryClient } from '@/data/query-client'
|
||||
import type { SqlSnippets } from '@/types'
|
||||
|
||||
type StateSnippetFolder = {
|
||||
projectRef: string
|
||||
folder: SnippetFolder
|
||||
status?: 'editing' | 'saving' | 'idle'
|
||||
}
|
||||
|
||||
type StateSnippet = {
|
||||
projectRef: string
|
||||
splitSizes: number[]
|
||||
snippet: SnippetWithContent
|
||||
}
|
||||
|
||||
// [Joshen] API codegen is somehow missing the content property
|
||||
export interface SnippetWithContent extends Snippet {
|
||||
content?: SqlSnippets.Content
|
||||
isNotSavedInDatabaseYet?: boolean
|
||||
}
|
||||
|
||||
const NEW_FOLDER_ID = 'new-folder'
|
||||
|
||||
export const sqlEditorState = proxy({
|
||||
// ========================================================================
|
||||
// ## Data properties within the store
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Currently limitations include supporting up to one level of folders from root, and only private snippets
|
||||
*/
|
||||
folders: {} as {
|
||||
[folderId: string]: StateSnippetFolder
|
||||
},
|
||||
|
||||
/**
|
||||
* Private and shared snippets only, favorite snippets are derivatives of them by the `favorite` property
|
||||
*/
|
||||
snippets: {} as {
|
||||
[snippetId: string]: StateSnippet
|
||||
},
|
||||
|
||||
/**
|
||||
* Query results, if any, for a snippet. Set as an array per snippetId as we were previously experimenting
|
||||
* with having a Jupyter notebook like UI but it never took off. Nonetheless kept this data structure as
|
||||
* we'd also want to support returning multiple results from a single query (e.g From a query that contains
|
||||
* multiple select statements), and this will allow us to do quite easily.
|
||||
*/
|
||||
results: {} as {
|
||||
[snippetId: string]: {
|
||||
rows: any[]
|
||||
error?: any
|
||||
autoLimit?: number
|
||||
}[]
|
||||
},
|
||||
|
||||
/**
|
||||
* Explain results, if any, for a snippet
|
||||
*/
|
||||
explainResults: {} as {
|
||||
[snippetId: string]: {
|
||||
rows: QueryPlanRow[]
|
||||
error?: { message: string; formattedError?: string }
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Synchronous saving of folders and snippets (debounce behavior). Key is the snippet id, value is shouldInvalidate
|
||||
*/
|
||||
needsSaving: proxyMap<string, boolean>([]),
|
||||
/**
|
||||
* Stores the state of each snippet
|
||||
*/
|
||||
savingStates: {} as {
|
||||
[snippetId: string]: 'IDLE' | 'UPDATING' | 'UPDATING_FAILED'
|
||||
},
|
||||
/**
|
||||
* UI-imposed limit for the number of results a query can return (applied to the SQL query being run if applicable).
|
||||
* Acts as a safeguard to prevent accidentally taking down the database from a really large SELECT query.
|
||||
* Related to `autoLimit` in `results`. Refer to `checkIfAppendLimitRequired` and `suffixWithLimit` for usage.
|
||||
*/
|
||||
limit: 100,
|
||||
|
||||
/**
|
||||
* Used for error handling after optimistical rendering from renaming a folder
|
||||
*/
|
||||
lastUpdatedFolderName: '',
|
||||
|
||||
/**
|
||||
* For Assistant to render diffing into the editor
|
||||
*/
|
||||
diffContent: undefined as undefined | { sql: string; diffType: DiffType },
|
||||
|
||||
get allFolderNames() {
|
||||
return Object.values(sqlEditorState.folders).map((x) => x.folder.name)
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// ## Methods to interact the store with
|
||||
// ========================================================================
|
||||
|
||||
setDiffContent: (sql: string, diffType: DiffType) =>
|
||||
(sqlEditorState.diffContent = { sql, diffType }),
|
||||
|
||||
/**
|
||||
* Load snippet into SQL Editor Valtio store
|
||||
*/
|
||||
addSnippet: ({ projectRef, snippet }: { projectRef: string; snippet: SnippetWithContent }) => {
|
||||
if (sqlEditorState.snippets[snippet.id]) return
|
||||
|
||||
sqlEditorState.snippets[snippet.id] = { projectRef, splitSizes: [50, 50], snippet }
|
||||
sqlEditorState.results[snippet.id] = []
|
||||
sqlEditorState.explainResults[snippet.id] = { rows: [] }
|
||||
sqlEditorState.savingStates[snippet.id] = 'IDLE'
|
||||
},
|
||||
|
||||
/**
|
||||
* Update snippet data (e.g name, visibility, chart) and queue for sync saving
|
||||
*/
|
||||
updateSnippet: ({
|
||||
id,
|
||||
snippet,
|
||||
skipSave = false,
|
||||
}: {
|
||||
id: string
|
||||
snippet: Partial<Snippet>
|
||||
skipSave?: boolean
|
||||
}) => {
|
||||
if (sqlEditorState.snippets[id]) {
|
||||
sqlEditorState.snippets[id].snippet = {
|
||||
...sqlEditorState.snippets[id].snippet,
|
||||
...snippet,
|
||||
}
|
||||
if (!skipSave) sqlEditorState.needsSaving.set(id, true)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load snippet content into the snippet within the Valtio store.
|
||||
* Snippets fetched from the GET /content or /folders endpoints do not have the content loaded initially
|
||||
* to reduce the response size from the API. Hence content for each snippet has to be loaded on demand
|
||||
*/
|
||||
setSnippet: (projectRef: string, snippet: SnippetWithContent) => {
|
||||
let storedSnippet = sqlEditorState.snippets[snippet.id]
|
||||
if (storedSnippet) {
|
||||
if (!storedSnippet.snippet.content) {
|
||||
storedSnippet.snippet.content = snippet.content
|
||||
}
|
||||
} else {
|
||||
sqlEditorState.addSnippet({ projectRef: projectRef, snippet })
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the snippet content of a snippet and queue for sync saving
|
||||
* Possibly can consolidate with `updateSnippet` to simplify
|
||||
*/
|
||||
setSql: ({
|
||||
id,
|
||||
sql,
|
||||
shouldInvalidate = false,
|
||||
}: {
|
||||
id: string
|
||||
sql: string
|
||||
shouldInvalidate?: boolean
|
||||
}) => {
|
||||
let snippet = sqlEditorState.snippets[id]?.snippet
|
||||
if (snippet?.content) {
|
||||
snippet.content.unchecked_sql = untrustedSql(sql)
|
||||
sqlEditorState.needsSaving.set(id, shouldInvalidate)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update snippet in Valtio store after renaming
|
||||
* Renaming a snippet follows an async saving and hence doesnt require queuing for sync saving here
|
||||
* Refer to `RenameQueryModal.tsx` for more details
|
||||
*/
|
||||
renameSnippet: ({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
}: {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}) => {
|
||||
let snippet = sqlEditorState.snippets[id]?.snippet
|
||||
if (snippet) {
|
||||
snippet.name = name
|
||||
snippet.description = description
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove snippet from the Valtio store, and optionally remove snippet from the sync saving queue
|
||||
*/
|
||||
removeSnippet: (id: string, skipSave: boolean = false) => {
|
||||
const { [id]: snippet, ...otherSnippets } = sqlEditorState.snippets
|
||||
sqlEditorState.snippets = otherSnippets
|
||||
|
||||
const { [id]: result, ...otherResults } = sqlEditorState.results
|
||||
sqlEditorState.results = otherResults
|
||||
|
||||
const { [id]: explainResult, ...otherExplainResults } = sqlEditorState.explainResults
|
||||
sqlEditorState.explainResults = otherExplainResults
|
||||
|
||||
if (!skipSave) sqlEditorState.needsSaving.delete(id)
|
||||
},
|
||||
|
||||
/**
|
||||
* Load folder into SQL Editor Valtio store
|
||||
*/
|
||||
addFolder: ({ projectRef, folder }: { projectRef: string; folder: SnippetFolder }) => {
|
||||
if (sqlEditorState.folders[folder.id]) return
|
||||
sqlEditorState.folders[folder.id] = { projectRef, folder }
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds a new folder placeholder for the UI to render
|
||||
*/
|
||||
addNewFolder: ({ projectRef }: { projectRef: string }) => {
|
||||
// [Joshen] Use this to identify new folders that have yet to be saved
|
||||
const id = NEW_FOLDER_ID
|
||||
sqlEditorState.folders[id] = {
|
||||
projectRef,
|
||||
status: 'editing',
|
||||
folder: {
|
||||
id,
|
||||
name: '',
|
||||
owner_id: -1,
|
||||
project_id: -1,
|
||||
parent_id: null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
editFolder: (id: string) => {
|
||||
sqlEditorState.folders[id].status = 'editing'
|
||||
},
|
||||
|
||||
/**
|
||||
* For renaming a folder, queue for sync saving if pass all validations
|
||||
*/
|
||||
saveFolder: ({ id, name }: { id: string; name: string }) => {
|
||||
let storeFolder = sqlEditorState.folders[id]
|
||||
const isNewFolder = id === 'new-folder'
|
||||
const hasChanges = storeFolder.folder.name !== name
|
||||
|
||||
if (isNewFolder && sqlEditorState.allFolderNames.includes(name)) {
|
||||
sqlEditorState.removeFolder(id)
|
||||
return toast.error('Unable to create new folder: This folder name already exists')
|
||||
} else if (hasChanges && sqlEditorState.allFolderNames.includes(name)) {
|
||||
storeFolder.status = 'idle'
|
||||
return toast.error('Unable to update folder: This folder name already exists')
|
||||
}
|
||||
|
||||
const originalFolderName = storeFolder.folder.name.slice()
|
||||
|
||||
storeFolder.status = hasChanges ? 'saving' : 'idle'
|
||||
storeFolder.folder.id = id
|
||||
storeFolder.folder.name = name
|
||||
|
||||
if (hasChanges) {
|
||||
sqlEditorState.lastUpdatedFolderName = originalFolderName
|
||||
sqlEditorState.needsSaving.set(id, true)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove folder from the Valtio store
|
||||
* Deleting a folder follows an async saving and hence doesnt require queuing for sync saving here
|
||||
* Refer to `SQLEditorNav` for more details (ConfirmationModal for deleting a folder)
|
||||
*/
|
||||
removeFolder: (id: string) => {
|
||||
const { [id]: folder, ...otherFolders } = sqlEditorState.folders
|
||||
sqlEditorState.folders = otherFolders
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the value for the auto limit for SELECT based SQL queries
|
||||
*/
|
||||
setLimit: (value: number) => (sqlEditorState.limit = value),
|
||||
|
||||
addNeedsSaving: (id: string) => sqlEditorState.needsSaving.set(id, true),
|
||||
|
||||
addFavorite: (id: string) => {
|
||||
const storeSnippet = sqlEditorState.snippets[id]
|
||||
if (storeSnippet) {
|
||||
storeSnippet.snippet.favorite = true
|
||||
sqlEditorState.needsSaving.set(id, true)
|
||||
}
|
||||
},
|
||||
|
||||
removeFavorite: (id: string) => {
|
||||
const storeSnippet = sqlEditorState.snippets[id]
|
||||
if (storeSnippet.snippet) {
|
||||
storeSnippet.snippet.favorite = false
|
||||
sqlEditorState.needsSaving.set(id, true)
|
||||
}
|
||||
},
|
||||
|
||||
addResult: (id: string, results: any[], autoLimit?: number) => {
|
||||
if (sqlEditorState.results[id]) {
|
||||
// Use ref() to prevent Valtio from creating proxies for each row object.
|
||||
// This is critical for large result sets - without ref(), Valtio wraps every
|
||||
// row and nested property in a Proxy, causing massive memory overhead.
|
||||
// Alright to use ref() in this case as the data is meant to be read-only and we
|
||||
// don't need to track changes to the underlying data
|
||||
sqlEditorState.results[id] = [{ rows: ref(results), autoLimit }]
|
||||
}
|
||||
},
|
||||
|
||||
addResultError: (id: string, error: any, autoLimit?: number) => {
|
||||
if (sqlEditorState.results[id]) {
|
||||
sqlEditorState.results[id] = [{ rows: ref([]), error, autoLimit }]
|
||||
}
|
||||
},
|
||||
|
||||
resetResult: (id: string) => {
|
||||
if (sqlEditorState.results[id]) {
|
||||
sqlEditorState.results[id] = []
|
||||
}
|
||||
},
|
||||
|
||||
addExplainResult: (id: string, results: QueryPlanRow[]) => {
|
||||
// Use ref() to prevent Valtio from creating proxies for each row object
|
||||
sqlEditorState.explainResults[id] = { rows: ref(results) }
|
||||
},
|
||||
|
||||
addExplainResultError: (id: string, error: { message: string; formattedError?: string }) => {
|
||||
sqlEditorState.explainResults[id] = { rows: ref([]), error }
|
||||
},
|
||||
|
||||
resetExplainResult: (id: string) => {
|
||||
sqlEditorState.explainResults[id] = { rows: [] }
|
||||
},
|
||||
|
||||
resetResults: (id: string) => {
|
||||
sqlEditorState.resetResult(id)
|
||||
sqlEditorState.resetExplainResult(id)
|
||||
},
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// ## Expose entry points into this Valtio store
|
||||
// ========================================================================
|
||||
|
||||
export const getSqlEditorV2StateSnapshot = () => snapshot(sqlEditorState)
|
||||
|
||||
export const useSqlEditorV2StateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) =>
|
||||
useSnapshot(sqlEditorState, options)
|
||||
|
||||
export const useSnippetFolders = (projectRef: string) => {
|
||||
const snapshot = useSqlEditorV2StateSnapshot()
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
Object.values(snapshot.folders)
|
||||
.filter((x) => x.projectRef === projectRef)
|
||||
.map((x) => x.folder)
|
||||
// folders don't have created_at or inserted_at, so we always sort by name
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[projectRef, snapshot.folders]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ALL snippets for a project
|
||||
*/
|
||||
export const useSnippets = (projectRef: string) => {
|
||||
const snapshot = useSqlEditorV2StateSnapshot()
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
Object.values(snapshot.snippets)
|
||||
.filter((storeSnippet) => storeSnippet.projectRef === projectRef)
|
||||
.map((storeSnippet) => storeSnippet.snippet),
|
||||
[projectRef, snapshot.snippets]
|
||||
)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// ## Below are all the asynchronous saving logic for the SQL Editor
|
||||
// ========================================================================
|
||||
|
||||
async function upsertSnippet(
|
||||
id: string,
|
||||
projectRef: string,
|
||||
payload: UpsertContentPayload,
|
||||
shouldInvalidate = false
|
||||
) {
|
||||
try {
|
||||
sqlEditorState.savingStates[id] = 'UPDATING'
|
||||
await upsertContent({ projectRef, payload })
|
||||
|
||||
if (shouldInvalidate) {
|
||||
const queryClient = getQueryClient()
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: contentKeys.count(projectRef, 'sql') }),
|
||||
queryClient.invalidateQueries({ queryKey: contentKeys.sqlSnippets(projectRef) }),
|
||||
queryClient.invalidateQueries({ queryKey: contentKeys.folders(projectRef) }),
|
||||
])
|
||||
}
|
||||
|
||||
let snippet = sqlEditorState.snippets[id]?.snippet
|
||||
if (snippet?.content && 'isNotSavedInDatabaseYet' in snippet) {
|
||||
snippet.isNotSavedInDatabaseYet = false
|
||||
}
|
||||
sqlEditorState.savingStates[id] = 'IDLE'
|
||||
} catch (error) {
|
||||
sqlEditorState.savingStates[id] = 'UPDATING_FAILED'
|
||||
}
|
||||
}
|
||||
|
||||
const memoizedUpsertSnippet = memoize((_id: string) => debounce(upsertSnippet, 1000))
|
||||
|
||||
const debouncedUpdateSnippet = (
|
||||
id: string,
|
||||
projectRef: string,
|
||||
payload: UpsertContentPayload,
|
||||
shouldInvalidate = false
|
||||
) => memoizedUpsertSnippet(id)(id, projectRef, payload, shouldInvalidate)
|
||||
|
||||
async function upsertFolder(id: string, projectRef: string, name: string) {
|
||||
try {
|
||||
if (id === NEW_FOLDER_ID) {
|
||||
const res = await createSQLSnippetFolder({ projectRef, name })
|
||||
toast.success('Successfully created folder')
|
||||
sqlEditorState.removeFolder(NEW_FOLDER_ID)
|
||||
sqlEditorState.folders[res.id] = { projectRef, status: 'idle', folder: res }
|
||||
} else {
|
||||
await updateSQLSnippetFolder({ projectRef, id, name })
|
||||
toast.success('Successfully updated folder')
|
||||
sqlEditorState.folders[id].status = 'idle'
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to save folder: ${error.message}`)
|
||||
if (error.message.includes('create')) {
|
||||
sqlEditorState.removeFolder(id)
|
||||
} else if (
|
||||
error.message.includes('update') &&
|
||||
sqlEditorState.lastUpdatedFolderName.length > 0
|
||||
) {
|
||||
let storeFolder = sqlEditorState.folders[id]
|
||||
|
||||
storeFolder.status = 'idle'
|
||||
storeFolder.folder.name = sqlEditorState.lastUpdatedFolderName
|
||||
}
|
||||
} finally {
|
||||
sqlEditorState.lastUpdatedFolderName = ''
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
devtools(sqlEditorState, {
|
||||
name: 'sqlEditorStateV2',
|
||||
// [Joshen] So that jest unit tests can ignore this
|
||||
enabled: process.env.NEXT_PUBLIC_ENVIRONMENT !== undefined,
|
||||
})
|
||||
|
||||
subscribe(sqlEditorState.needsSaving, () => {
|
||||
const state = getSqlEditorV2StateSnapshot()
|
||||
|
||||
state.needsSaving.forEach((shouldInvalidate, id) => {
|
||||
const snippet = state.snippets[id]
|
||||
const folder = state.folders[id]
|
||||
|
||||
if (snippet) {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
visibility,
|
||||
project_id,
|
||||
owner_id,
|
||||
folder_id,
|
||||
content,
|
||||
favorite,
|
||||
} = snippet.snippet
|
||||
|
||||
if (visibility === 'project' && !!folder_id) {
|
||||
toast.error('Shared snippet cannot be within a folder')
|
||||
} else {
|
||||
debouncedUpdateSnippet(
|
||||
id,
|
||||
snippet.projectRef,
|
||||
{
|
||||
id,
|
||||
type: 'sql',
|
||||
name: name ?? 'Untitled',
|
||||
description: description ?? '',
|
||||
visibility: visibility ?? 'user',
|
||||
project_id: project_id ?? 0,
|
||||
owner_id: owner_id,
|
||||
folder_id: folder_id ?? undefined,
|
||||
favorite: favorite ?? false,
|
||||
content: {
|
||||
...content!,
|
||||
content_id: id,
|
||||
},
|
||||
},
|
||||
shouldInvalidate
|
||||
)
|
||||
sqlEditorState.needsSaving.delete(id)
|
||||
}
|
||||
} else if (folder) {
|
||||
upsertFolder(id, folder.projectRef, folder.folder.name)
|
||||
sqlEditorState.needsSaving.delete(id)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
export type { StateSnippet, StateSnippetFolder, SnippetWithContent } from './sql-editor'
|
||||
export {
|
||||
sqlEditorState,
|
||||
getSqlEditorV2StateSnapshot,
|
||||
useSqlEditorV2StateSnapshot,
|
||||
useSnippetFolders,
|
||||
useSnippets,
|
||||
} from './sql-editor'
|
||||
@@ -0,0 +1,8 @@
|
||||
export type { StateSnippet, StateSnippetFolder, SnippetWithContent } from './types'
|
||||
export {
|
||||
sqlEditorState,
|
||||
getSqlEditorV2StateSnapshot,
|
||||
useSqlEditorV2StateSnapshot,
|
||||
useSnippetFolders,
|
||||
useSnippets,
|
||||
} from './sql-editor-state'
|
||||
@@ -0,0 +1,507 @@
|
||||
import { untrustedSql } from '@supabase/pg-meta'
|
||||
import { debounce, memoize } from 'lodash'
|
||||
import { useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio'
|
||||
import { devtools, proxyMap } from 'valtio/utils'
|
||||
|
||||
import type { SnippetWithContent, StateSnippet, StateSnippetFolder } from './types'
|
||||
import type { QueryPlanRow } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.types'
|
||||
import { DiffType } from '@/components/interfaces/SQLEditor/SQLEditor.types'
|
||||
import { upsertContent, UpsertContentPayload } from '@/data/content/content-upsert-mutation'
|
||||
import { contentKeys } from '@/data/content/keys'
|
||||
import { createSQLSnippetFolder } from '@/data/content/sql-folder-create-mutation'
|
||||
import { updateSQLSnippetFolder } from '@/data/content/sql-folder-update-mutation'
|
||||
import { Snippet, SnippetFolder } from '@/data/content/sql-folders-query'
|
||||
import { getQueryClient } from '@/data/query-client'
|
||||
|
||||
const NEW_FOLDER_ID = 'new-folder'
|
||||
|
||||
export const sqlEditorState = proxy({
|
||||
// ========================================================================
|
||||
// ## Data properties within the store
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Currently limitations include supporting up to one level of folders from root, and only private snippets
|
||||
*/
|
||||
folders: {} as {
|
||||
[folderId: string]: StateSnippetFolder
|
||||
},
|
||||
|
||||
/**
|
||||
* Private and shared snippets only, favorite snippets are derivatives of them by the `favorite` property
|
||||
*/
|
||||
snippets: {} as {
|
||||
[snippetId: string]: StateSnippet
|
||||
},
|
||||
|
||||
/**
|
||||
* Query results, if any, for a snippet. Set as an array per snippetId as we were previously experimenting
|
||||
* with having a Jupyter notebook like UI but it never took off. Nonetheless kept this data structure as
|
||||
* we'd also want to support returning multiple results from a single query (e.g From a query that contains
|
||||
* multiple select statements), and this will allow us to do quite easily.
|
||||
*/
|
||||
results: {} as {
|
||||
[snippetId: string]: {
|
||||
rows: any[]
|
||||
error?: any
|
||||
autoLimit?: number
|
||||
}[]
|
||||
},
|
||||
|
||||
/**
|
||||
* Explain results, if any, for a snippet
|
||||
*/
|
||||
explainResults: {} as {
|
||||
[snippetId: string]: {
|
||||
rows: QueryPlanRow[]
|
||||
error?: { message: string; formattedError?: string }
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Synchronous saving of folders and snippets (debounce behavior). Key is the snippet id, value is shouldInvalidate
|
||||
*/
|
||||
needsSaving: proxyMap<string, boolean>([]),
|
||||
/**
|
||||
* Stores the state of each snippet
|
||||
*/
|
||||
savingStates: {} as {
|
||||
[snippetId: string]: 'IDLE' | 'UPDATING' | 'UPDATING_FAILED'
|
||||
},
|
||||
/**
|
||||
* UI-imposed limit for the number of results a query can return (applied to the SQL query being run if applicable).
|
||||
* Acts as a safeguard to prevent accidentally taking down the database from a really large SELECT query.
|
||||
* Related to `autoLimit` in `results`. Refer to `checkIfAppendLimitRequired` and `suffixWithLimit` for usage.
|
||||
*/
|
||||
limit: 100,
|
||||
|
||||
/**
|
||||
* Used for error handling after optimistical rendering from renaming a folder
|
||||
*/
|
||||
lastUpdatedFolderName: '',
|
||||
|
||||
/**
|
||||
* For Assistant to render diffing into the editor
|
||||
*/
|
||||
diffContent: undefined as undefined | { sql: string; diffType: DiffType },
|
||||
|
||||
get allFolderNames() {
|
||||
return Object.values(sqlEditorState.folders).map((x) => x.folder.name)
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// ## Methods to interact the store with
|
||||
// ========================================================================
|
||||
|
||||
setDiffContent: (sql: string, diffType: DiffType) =>
|
||||
(sqlEditorState.diffContent = { sql, diffType }),
|
||||
|
||||
/**
|
||||
* Load snippet into SQL Editor Valtio store
|
||||
*/
|
||||
addSnippet: ({ projectRef, snippet }: { projectRef: string; snippet: SnippetWithContent }) => {
|
||||
if (sqlEditorState.snippets[snippet.id]) return
|
||||
|
||||
sqlEditorState.snippets[snippet.id] = { projectRef, splitSizes: [50, 50], snippet }
|
||||
sqlEditorState.results[snippet.id] = []
|
||||
sqlEditorState.explainResults[snippet.id] = { rows: [] }
|
||||
sqlEditorState.savingStates[snippet.id] = 'IDLE'
|
||||
},
|
||||
|
||||
/**
|
||||
* Update snippet data (e.g name, visibility, chart) and queue for sync saving
|
||||
*/
|
||||
updateSnippet: ({
|
||||
id,
|
||||
snippet,
|
||||
skipSave = false,
|
||||
}: {
|
||||
id: string
|
||||
snippet: Partial<Snippet>
|
||||
skipSave?: boolean
|
||||
}) => {
|
||||
if (sqlEditorState.snippets[id]) {
|
||||
sqlEditorState.snippets[id].snippet = {
|
||||
...sqlEditorState.snippets[id].snippet,
|
||||
...snippet,
|
||||
}
|
||||
if (!skipSave) sqlEditorState.needsSaving.set(id, true)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load snippet content into the snippet within the Valtio store.
|
||||
* Snippets fetched from the GET /content or /folders endpoints do not have the content loaded initially
|
||||
* to reduce the response size from the API. Hence content for each snippet has to be loaded on demand
|
||||
*/
|
||||
setSnippet: (projectRef: string, snippet: SnippetWithContent) => {
|
||||
let storedSnippet = sqlEditorState.snippets[snippet.id]
|
||||
if (storedSnippet) {
|
||||
if (!storedSnippet.snippet.content) {
|
||||
storedSnippet.snippet.content = snippet.content
|
||||
}
|
||||
} else {
|
||||
sqlEditorState.addSnippet({ projectRef: projectRef, snippet })
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the snippet content of a snippet and queue for sync saving
|
||||
* Possibly can consolidate with `updateSnippet` to simplify
|
||||
*/
|
||||
setSql: ({
|
||||
id,
|
||||
sql,
|
||||
shouldInvalidate = false,
|
||||
}: {
|
||||
id: string
|
||||
sql: string
|
||||
shouldInvalidate?: boolean
|
||||
}) => {
|
||||
let snippet = sqlEditorState.snippets[id]?.snippet
|
||||
if (snippet?.content) {
|
||||
snippet.content.unchecked_sql = untrustedSql(sql)
|
||||
sqlEditorState.needsSaving.set(id, shouldInvalidate)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update snippet in Valtio store after renaming
|
||||
* Renaming a snippet follows an async saving and hence doesnt require queuing for sync saving here
|
||||
* Refer to `RenameQueryModal.tsx` for more details
|
||||
*/
|
||||
renameSnippet: ({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
}: {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}) => {
|
||||
let snippet = sqlEditorState.snippets[id]?.snippet
|
||||
if (snippet) {
|
||||
snippet.name = name
|
||||
snippet.description = description
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove snippet from the Valtio store, and optionally remove snippet from the sync saving queue
|
||||
*/
|
||||
removeSnippet: (id: string, skipSave: boolean = false) => {
|
||||
const { [id]: snippet, ...otherSnippets } = sqlEditorState.snippets
|
||||
sqlEditorState.snippets = otherSnippets
|
||||
|
||||
const { [id]: result, ...otherResults } = sqlEditorState.results
|
||||
sqlEditorState.results = otherResults
|
||||
|
||||
const { [id]: explainResult, ...otherExplainResults } = sqlEditorState.explainResults
|
||||
sqlEditorState.explainResults = otherExplainResults
|
||||
|
||||
if (!skipSave) sqlEditorState.needsSaving.delete(id)
|
||||
},
|
||||
|
||||
/**
|
||||
* Load folder into SQL Editor Valtio store
|
||||
*/
|
||||
addFolder: ({ projectRef, folder }: { projectRef: string; folder: SnippetFolder }) => {
|
||||
if (sqlEditorState.folders[folder.id]) return
|
||||
sqlEditorState.folders[folder.id] = { projectRef, folder }
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds a new folder placeholder for the UI to render
|
||||
*/
|
||||
addNewFolder: ({ projectRef }: { projectRef: string }) => {
|
||||
// [Joshen] Use this to identify new folders that have yet to be saved
|
||||
const id = NEW_FOLDER_ID
|
||||
sqlEditorState.folders[id] = {
|
||||
projectRef,
|
||||
status: 'editing',
|
||||
folder: {
|
||||
id,
|
||||
name: '',
|
||||
owner_id: -1,
|
||||
project_id: -1,
|
||||
parent_id: null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
editFolder: (id: string) => {
|
||||
sqlEditorState.folders[id].status = 'editing'
|
||||
},
|
||||
|
||||
/**
|
||||
* For renaming a folder, queue for sync saving if pass all validations
|
||||
*/
|
||||
saveFolder: ({ id, name }: { id: string; name: string }) => {
|
||||
let storeFolder = sqlEditorState.folders[id]
|
||||
const isNewFolder = id === 'new-folder'
|
||||
const hasChanges = storeFolder.folder.name !== name
|
||||
|
||||
if (isNewFolder && sqlEditorState.allFolderNames.includes(name)) {
|
||||
sqlEditorState.removeFolder(id)
|
||||
return toast.error('Unable to create new folder: This folder name already exists')
|
||||
} else if (hasChanges && sqlEditorState.allFolderNames.includes(name)) {
|
||||
storeFolder.status = 'idle'
|
||||
return toast.error('Unable to update folder: This folder name already exists')
|
||||
}
|
||||
|
||||
const originalFolderName = storeFolder.folder.name.slice()
|
||||
|
||||
storeFolder.status = hasChanges ? 'saving' : 'idle'
|
||||
storeFolder.folder.id = id
|
||||
storeFolder.folder.name = name
|
||||
|
||||
if (hasChanges) {
|
||||
sqlEditorState.lastUpdatedFolderName = originalFolderName
|
||||
sqlEditorState.needsSaving.set(id, true)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove folder from the Valtio store
|
||||
* Deleting a folder follows an async saving and hence doesnt require queuing for sync saving here
|
||||
* Refer to `SQLEditorNav` for more details (ConfirmationModal for deleting a folder)
|
||||
*/
|
||||
removeFolder: (id: string) => {
|
||||
const { [id]: folder, ...otherFolders } = sqlEditorState.folders
|
||||
sqlEditorState.folders = otherFolders
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the value for the auto limit for SELECT based SQL queries
|
||||
*/
|
||||
setLimit: (value: number) => (sqlEditorState.limit = value),
|
||||
|
||||
addNeedsSaving: (id: string) => sqlEditorState.needsSaving.set(id, true),
|
||||
|
||||
addFavorite: (id: string) => {
|
||||
const storeSnippet = sqlEditorState.snippets[id]
|
||||
if (storeSnippet) {
|
||||
storeSnippet.snippet.favorite = true
|
||||
sqlEditorState.needsSaving.set(id, true)
|
||||
}
|
||||
},
|
||||
|
||||
removeFavorite: (id: string) => {
|
||||
const storeSnippet = sqlEditorState.snippets[id]
|
||||
if (storeSnippet.snippet) {
|
||||
storeSnippet.snippet.favorite = false
|
||||
sqlEditorState.needsSaving.set(id, true)
|
||||
}
|
||||
},
|
||||
|
||||
addResult: (id: string, results: any[], autoLimit?: number) => {
|
||||
if (sqlEditorState.results[id]) {
|
||||
// Use ref() to prevent Valtio from creating proxies for each row object.
|
||||
// This is critical for large result sets - without ref(), Valtio wraps every
|
||||
// row and nested property in a Proxy, causing massive memory overhead.
|
||||
// Alright to use ref() in this case as the data is meant to be read-only and we
|
||||
// don't need to track changes to the underlying data
|
||||
sqlEditorState.results[id] = [{ rows: ref(results), autoLimit }]
|
||||
}
|
||||
},
|
||||
|
||||
addResultError: (id: string, error: any, autoLimit?: number) => {
|
||||
if (sqlEditorState.results[id]) {
|
||||
sqlEditorState.results[id] = [{ rows: ref([]), error, autoLimit }]
|
||||
}
|
||||
},
|
||||
|
||||
resetResult: (id: string) => {
|
||||
if (sqlEditorState.results[id]) {
|
||||
sqlEditorState.results[id] = []
|
||||
}
|
||||
},
|
||||
|
||||
addExplainResult: (id: string, results: QueryPlanRow[]) => {
|
||||
// Use ref() to prevent Valtio from creating proxies for each row object
|
||||
sqlEditorState.explainResults[id] = { rows: ref(results) }
|
||||
},
|
||||
|
||||
addExplainResultError: (id: string, error: { message: string; formattedError?: string }) => {
|
||||
sqlEditorState.explainResults[id] = { rows: ref([]), error }
|
||||
},
|
||||
|
||||
resetExplainResult: (id: string) => {
|
||||
sqlEditorState.explainResults[id] = { rows: [] }
|
||||
},
|
||||
|
||||
resetResults: (id: string) => {
|
||||
sqlEditorState.resetResult(id)
|
||||
sqlEditorState.resetExplainResult(id)
|
||||
},
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// ## Expose entry points into this Valtio store
|
||||
// ========================================================================
|
||||
|
||||
export const getSqlEditorV2StateSnapshot = () => snapshot(sqlEditorState)
|
||||
|
||||
export const useSqlEditorV2StateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) =>
|
||||
useSnapshot(sqlEditorState, options)
|
||||
|
||||
export const useSnippetFolders = (projectRef: string) => {
|
||||
const snapshot = useSqlEditorV2StateSnapshot()
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
Object.values(snapshot.folders)
|
||||
.filter((x) => x.projectRef === projectRef)
|
||||
.map((x) => x.folder)
|
||||
// folders don't have created_at or inserted_at, so we always sort by name
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[projectRef, snapshot.folders]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ALL snippets for a project
|
||||
*/
|
||||
export const useSnippets = (projectRef: string) => {
|
||||
const snapshot = useSqlEditorV2StateSnapshot()
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
Object.values(snapshot.snippets)
|
||||
.filter((storeSnippet) => storeSnippet.projectRef === projectRef)
|
||||
.map((storeSnippet) => storeSnippet.snippet),
|
||||
[projectRef, snapshot.snippets]
|
||||
)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// ## Below are all the asynchronous saving logic for the SQL Editor
|
||||
// ========================================================================
|
||||
|
||||
async function upsertSnippet(
|
||||
id: string,
|
||||
projectRef: string,
|
||||
payload: UpsertContentPayload,
|
||||
shouldInvalidate = false
|
||||
) {
|
||||
try {
|
||||
sqlEditorState.savingStates[id] = 'UPDATING'
|
||||
await upsertContent({ projectRef, payload })
|
||||
|
||||
if (shouldInvalidate) {
|
||||
const queryClient = getQueryClient()
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: contentKeys.count(projectRef, 'sql') }),
|
||||
queryClient.invalidateQueries({ queryKey: contentKeys.sqlSnippets(projectRef) }),
|
||||
queryClient.invalidateQueries({ queryKey: contentKeys.folders(projectRef) }),
|
||||
])
|
||||
}
|
||||
|
||||
let snippet = sqlEditorState.snippets[id]?.snippet
|
||||
if (snippet?.content && 'isNotSavedInDatabaseYet' in snippet) {
|
||||
snippet.isNotSavedInDatabaseYet = false
|
||||
}
|
||||
sqlEditorState.savingStates[id] = 'IDLE'
|
||||
} catch (error) {
|
||||
sqlEditorState.savingStates[id] = 'UPDATING_FAILED'
|
||||
}
|
||||
}
|
||||
|
||||
const memoizedUpsertSnippet = memoize((_id: string) => debounce(upsertSnippet, 1000))
|
||||
|
||||
const debouncedUpdateSnippet = (
|
||||
id: string,
|
||||
projectRef: string,
|
||||
payload: UpsertContentPayload,
|
||||
shouldInvalidate = false
|
||||
) => memoizedUpsertSnippet(id)(id, projectRef, payload, shouldInvalidate)
|
||||
|
||||
async function upsertFolder(id: string, projectRef: string, name: string) {
|
||||
try {
|
||||
if (id === NEW_FOLDER_ID) {
|
||||
const res = await createSQLSnippetFolder({ projectRef, name })
|
||||
toast.success('Successfully created folder')
|
||||
sqlEditorState.removeFolder(NEW_FOLDER_ID)
|
||||
sqlEditorState.folders[res.id] = { projectRef, status: 'idle', folder: res }
|
||||
} else {
|
||||
await updateSQLSnippetFolder({ projectRef, id, name })
|
||||
toast.success('Successfully updated folder')
|
||||
sqlEditorState.folders[id].status = 'idle'
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to save folder: ${error.message}`)
|
||||
if (error.message.includes('create')) {
|
||||
sqlEditorState.removeFolder(id)
|
||||
} else if (
|
||||
error.message.includes('update') &&
|
||||
sqlEditorState.lastUpdatedFolderName.length > 0
|
||||
) {
|
||||
let storeFolder = sqlEditorState.folders[id]
|
||||
|
||||
storeFolder.status = 'idle'
|
||||
storeFolder.folder.name = sqlEditorState.lastUpdatedFolderName
|
||||
}
|
||||
} finally {
|
||||
sqlEditorState.lastUpdatedFolderName = ''
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
devtools(sqlEditorState, {
|
||||
name: 'sqlEditorStateV2',
|
||||
// [Joshen] So that jest unit tests can ignore this
|
||||
enabled: process.env.NEXT_PUBLIC_ENVIRONMENT !== undefined,
|
||||
})
|
||||
|
||||
subscribe(sqlEditorState.needsSaving, () => {
|
||||
const state = getSqlEditorV2StateSnapshot()
|
||||
|
||||
state.needsSaving.forEach((shouldInvalidate, id) => {
|
||||
const snippet = state.snippets[id]
|
||||
const folder = state.folders[id]
|
||||
|
||||
if (snippet) {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
visibility,
|
||||
project_id,
|
||||
owner_id,
|
||||
folder_id,
|
||||
content,
|
||||
favorite,
|
||||
} = snippet.snippet
|
||||
|
||||
if (visibility === 'project' && !!folder_id) {
|
||||
toast.error('Shared snippet cannot be within a folder')
|
||||
} else {
|
||||
debouncedUpdateSnippet(
|
||||
id,
|
||||
snippet.projectRef,
|
||||
{
|
||||
id,
|
||||
type: 'sql',
|
||||
name: name ?? 'Untitled',
|
||||
description: description ?? '',
|
||||
visibility: visibility ?? 'user',
|
||||
project_id: project_id ?? 0,
|
||||
owner_id: owner_id,
|
||||
folder_id: folder_id ?? undefined,
|
||||
favorite: favorite ?? false,
|
||||
content: {
|
||||
...content!,
|
||||
content_id: id,
|
||||
},
|
||||
},
|
||||
shouldInvalidate
|
||||
)
|
||||
sqlEditorState.needsSaving.delete(id)
|
||||
}
|
||||
} else if (folder) {
|
||||
upsertFolder(id, folder.projectRef, folder.folder.name)
|
||||
sqlEditorState.needsSaving.delete(id)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Snippet, SnippetFolder } from '@/data/content/sql-folders-query'
|
||||
import type { SqlSnippets } from '@/types'
|
||||
|
||||
export type StateSnippetFolder = {
|
||||
projectRef: string
|
||||
folder: SnippetFolder
|
||||
status?: 'editing' | 'saving' | 'idle'
|
||||
}
|
||||
|
||||
export type StateSnippet = {
|
||||
projectRef: string
|
||||
splitSizes: number[]
|
||||
snippet: SnippetWithContent
|
||||
}
|
||||
|
||||
// [Joshen] API codegen is somehow missing the content property
|
||||
export interface SnippetWithContent extends Snippet {
|
||||
content?: SqlSnippets.Content
|
||||
isNotSavedInDatabaseYet?: boolean
|
||||
}
|
||||
Reference in new issue
Block a user