mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 18:35:07 +03:00
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.
YES
## What kind of change does this PR introduce?
Bug fix
## What is the current behavior?
Fixes #48110
In the SQL editor Valtio store, `removeFavorite` guards against a
missing snippet with `if (storeSnippet.snippet)`, which reads `.snippet`
off `undefined` and throws `TypeError: Cannot read properties of
undefined (reading 'snippet')` whenever the id is not loaded in
`sqlEditorState.snippets`. Its counterpart `addFavorite` guards
correctly with `if (storeSnippet)` and no-ops on the same input.
## What is the new behavior?
`removeFavorite` now uses the same `if (storeSnippet)` guard as
`addFavorite`, so un-favoriting an id that is not in the store is a safe
no-op instead of a crash. Behavior for loaded snippets is unchanged.
Since `StateSnippet.snippet` is a required field, the old check was
always true whenever `storeSnippet` existed, so the only real world
difference between the two guards was the crash on the missing case.
I also added a small vitest file covering both methods (favorite set
plus needsSaving queued for loaded snippets, no-op for missing ids). The
missing-id test for `removeFavorite` fails with the exact TypeError
above when run against the old guard, and passes with this fix.
## Additional context
Root cause: `apps/studio/state/sql-editor/sql-editor-state.ts` line 260
(compare `removeFavorite` at lines 258 to 264 with `addFavorite` at
lines 250 to 256).
Gates run locally on top of current master (45ba40eff9): `pnpm
test:prettier`, `pnpm typecheck` (8/8 packages), `pnpm lint
--filter=studio` (0 errors), `pnpm test:studio` (only failure is
`lib/local-storage.test.ts`, which fails identically on clean master),
and `pnpm build --filter=studio`.
quick disclosure: I traced this one down and built the fix and test with
help from Claude Code, then verified everything locally myself. still a
college freshman finding my way around this codebase, so if the minimal
guard fix is not the direction you want (for example collapsing both
methods into one setFavorite), happy to rework it :)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Fixed favorite removal so it works correctly for saved SQL snippets.
* Prevented favorite and unfavorite actions from causing errors when the
specified snippet cannot be found.
* **Tests**
* Added coverage for favoriting, unfavoriting, saving state updates, and
missing snippets.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
312 lines
9.8 KiB
TypeScript
312 lines
9.8 KiB
TypeScript
import { untrustedSql } from '@supabase/pg-meta'
|
|
import { useMemo } from 'react'
|
|
import { toast } from 'sonner'
|
|
import { proxy, snapshot, useSnapshot } from 'valtio'
|
|
import { devtools, proxyMap } from 'valtio/utils'
|
|
|
|
import {
|
|
folderStatusOnSaveStart,
|
|
isNewFolder,
|
|
statusOnDiscard,
|
|
statusOnEdit,
|
|
} from './sql-editor-lifecycle'
|
|
import { sqlEditorSessionState } from './sql-editor-session-state'
|
|
import type { StateSnippet, StateSnippetFolder } from './types'
|
|
import type { SnippetWithContent } from '@/data/content/sql-folders-query'
|
|
import { Snippet, SnippetFolder } from '@/data/content/sql-folders-query'
|
|
|
|
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
|
|
},
|
|
|
|
/**
|
|
* Snippets queued for saving. Key is the snippet id, value is whether saving
|
|
* it should also invalidate the snippet/folder lists.
|
|
*/
|
|
needsSaving: proxyMap<string, boolean>([]),
|
|
/**
|
|
* Folders queued for saving (create or rename). Kept separate from
|
|
* `needsSaving` so snippet and folder saves are scheduled independently.
|
|
*/
|
|
pendingFolderSaves: proxyMap<string, boolean>([]),
|
|
|
|
get allFolderNames() {
|
|
return Object.values(sqlEditorState.folders).map((x) => x.folder.name)
|
|
},
|
|
|
|
// ========================================================================
|
|
// ## Methods to interact the store with
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 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 }
|
|
},
|
|
|
|
/**
|
|
*
|
|
* Clear local snippet content that is not persisted to the database. Deletes
|
|
* user edits that have not been saved.
|
|
*/
|
|
clearSnippetContent: (id: string) => {
|
|
const storeSnippet = sqlEditorState.snippets[id]
|
|
if (storeSnippet) {
|
|
storeSnippet.snippet.content = undefined
|
|
storeSnippet.snippet.status = statusOnDiscard(storeSnippet.snippet.status)
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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)
|
|
// Mark dirty immediately so `status` (not the transient needsSaving queue)
|
|
// is the durable source of truth for unsaved changes, even before the
|
|
// debounced save begins.
|
|
snippet.status = statusOnEdit(snippet.status)
|
|
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
|
|
|
|
// Results/explain live in the session store; drop this snippet's entries.
|
|
sqlEditorSessionState.clearForSnippet(id)
|
|
|
|
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, status: 'idle', folder }
|
|
},
|
|
|
|
/**
|
|
* Adds a new folder placeholder for the UI to render. The placeholder gets a
|
|
* unique local id and `status: 'new_editing'` to mark it as not-yet-persisted
|
|
* (the status, not the id, is what tags it as new).
|
|
*/
|
|
addNewFolder: ({ projectRef }: { projectRef: string }) => {
|
|
const id = crypto.randomUUID()
|
|
sqlEditorState.folders[id] = {
|
|
projectRef,
|
|
status: 'new_editing',
|
|
folder: {
|
|
id,
|
|
name: '',
|
|
owner_id: -1,
|
|
project_id: -1,
|
|
parent_id: null,
|
|
},
|
|
}
|
|
},
|
|
|
|
editFolder: (id: string) => {
|
|
const storeFolder = sqlEditorState.folders[id]
|
|
if (storeFolder) {
|
|
storeFolder.status = isNewFolder(storeFolder.status) ? 'new_editing' : '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 isNew = isNewFolder(storeFolder.status)
|
|
const hasChanges = storeFolder.folder.name !== name
|
|
const folderNameTaken = sqlEditorState.allFolderNames.includes(name)
|
|
|
|
if (isNew && folderNameTaken) {
|
|
sqlEditorState.removeFolder(id)
|
|
return toast.error('Unable to create new folder: This folder name already exists')
|
|
} else if (hasChanges && folderNameTaken) {
|
|
storeFolder.status = 'idle'
|
|
return toast.error('Unable to update folder: This folder name already exists')
|
|
}
|
|
|
|
const originalFolderName = storeFolder.folder.name.slice()
|
|
|
|
storeFolder.status = hasChanges ? folderStatusOnSaveStart(storeFolder.status) : 'idle'
|
|
storeFolder.folder.id = id
|
|
storeFolder.folder.name = name
|
|
|
|
if (hasChanges) {
|
|
// Remember this folder's own pre-rename name so a failed save can roll back.
|
|
storeFolder.previousName = originalFolderName
|
|
sqlEditorState.pendingFolderSaves.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
|
|
},
|
|
|
|
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) {
|
|
storeSnippet.snippet.favorite = false
|
|
sqlEditorState.needsSaving.set(id, true)
|
|
}
|
|
},
|
|
})
|
|
|
|
// ========================================================================
|
|
// ## 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]
|
|
)
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
devtools(sqlEditorState, {
|
|
name: 'sqlEditorStateV2',
|
|
// [Joshen] So that jest unit tests can ignore this
|
|
enabled: process.env.NEXT_PUBLIC_ENVIRONMENT !== undefined,
|
|
})
|
|
}
|