mirror of
https://github.com/supabase/supabase.git
synced 2026-10-13 13:19:09 +03:00
refactor(studio): unify snippet save + persistence into SnippetStatus
PR 3 of a stacked refactor. Replaces the two overlapping pieces of snippet
lifecycle state — the savingStates map ('IDLE'|'UPDATING'|'UPDATING_FAILED')
and the isNotSavedInDatabaseYet boolean — with a single SnippetStatus enum.
Save progress and persistence are orthogonal, so the never-persisted 'new'
phase keeps its own saving/failed variants (new, new_saving, new_save_failed)
alongside the persisted ones (saved, unsaved, saving, save_failed). The
wasNeverPersisted() predicate recovers the persistence axis and stays true
across the entire first-save lifecycle, so content-fetching, list
invalidation, and the replication-lag 404 swallow all map faithfully — and a
new snippet's first save keeps its saving/failed indicator.
Status is attached at the data layer so a snippet is never without one:
- SnippetStatus + SnippetWithContent now live in data/content; the snippet
queries attach status 'saved' via withSavedStatus(), and upsertContent
returns SnippetWithContent so move/rename responses carry status too.
- A SQL-typed getSqlSnippetById/useSqlSnippetByIdQuery returns
SnippetWithContent (the generic useContentIdQuery stays for Reports), so
[id].tsx loads content with no casting.
- 'new' is attached on local creation (createSqlSnippetSkeletonV2).
Dependents import SnippetWithContent/SnippetStatus directly from the data
layer (no state-layer re-exports). 'unsaved' is reserved for manual-save
dirty tracking in a later PR. Pure unit tests for every predicate/transition.
This commit is contained in:
1 parent
e2121aedd8
commit
cc5548dbe0
23 files changed
+297
-74
No files matched your search
@@ -19,6 +19,7 @@ import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled'
|
||||
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
|
||||
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
|
||||
import { wasNeverPersisted } from '@/state/sql-editor/sql-editor-lifecycle'
|
||||
import { canEditSnippet } from '@/state/sql-editor/sql-editor-rules'
|
||||
import { useTabsStateSnapshot } from '@/state/tabs'
|
||||
|
||||
@@ -237,7 +238,7 @@ export const MonacoEditor = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedValue.length > 0 && snippet) {
|
||||
const shouldInvalidate = snippet.snippet.isNotSavedInDatabaseYet
|
||||
const shouldInvalidate = wasNeverPersisted(snippet.snippet.status)
|
||||
snapV2.setSql({ id, sql: value, shouldInvalidate })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -41,11 +41,8 @@ import { getContentById } from '@/data/content/content-id-query'
|
||||
import { useContentUpsertMutation } from '@/data/content/content-upsert-mutation'
|
||||
import { useSQLSnippetFolderCreateMutation } from '@/data/content/sql-folder-create-mutation'
|
||||
import { Snippet } from '@/data/content/sql-folders-query'
|
||||
import {
|
||||
SnippetWithContent,
|
||||
useSnippetFolders,
|
||||
useSqlEditorV2StateSnapshot,
|
||||
} from '@/state/sql-editor-v2'
|
||||
import type { SnippetWithContent } from '@/data/content/sql-folders-query'
|
||||
import { useSnippetFolders, useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
|
||||
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
|
||||
|
||||
interface MoveQueryModalProps {
|
||||
|
||||
@@ -26,7 +26,7 @@ import * as z from 'zod'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { useCheckOpenAIKeyQuery } from '@/data/ai/check-api-key-query'
|
||||
import { useSqlTitleGenerateMutation } from '@/data/ai/sql-title-mutation'
|
||||
import { getContentById } from '@/data/content/content-id-query'
|
||||
import { getContentById, getSqlSnippetById } from '@/data/content/content-id-query'
|
||||
import {
|
||||
UpsertContentPayload,
|
||||
useContentUpsertMutation,
|
||||
@@ -109,8 +109,9 @@ export const RenameQueryModal = ({
|
||||
|
||||
// [Joshen] For SQL V2 - content is loaded on demand so we need to fetch the data if its not already loaded in the valtio state
|
||||
if (!('content' in localSnippet)) {
|
||||
localSnippet = await getContentById({ projectRef: ref, id })
|
||||
snapV2.addSnippet({ projectRef: ref, snippet: localSnippet })
|
||||
const fetched = await getSqlSnippetById({ projectRef: ref, id })
|
||||
snapV2.addSnippet({ projectRef: ref, snippet: fetched })
|
||||
localSnippet = fetched
|
||||
}
|
||||
|
||||
const changedSnippet = await upsertContent({
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
updateWithoutWhereRegex,
|
||||
} from './SQLEditor.constants'
|
||||
import { ContentDiff } from './SQLEditor.types'
|
||||
import type { SnippetWithContent } from '@/data/content/sql-folders-query'
|
||||
import type { DatabaseEventTrigger } from '@/data/database-event-triggers/database-event-triggers-query'
|
||||
import { generateUuid } from '@/lib/api/snippets.browser'
|
||||
import { removeCommentsFromSql } from '@/lib/helpers'
|
||||
import { sqlEventParser } from '@/lib/sql-event-parser'
|
||||
import type { SnippetWithContent } from '@/state/sql-editor-v2'
|
||||
|
||||
export type CreateTableWithoutRLS = {
|
||||
schema?: string
|
||||
@@ -83,7 +83,7 @@ export const createSqlSnippetSkeletonV2 = ({
|
||||
content_id: id ?? '',
|
||||
unchecked_sql: untrustedSql(sql ?? ''),
|
||||
} as any,
|
||||
isNotSavedInDatabaseYet: true,
|
||||
status: 'new',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import ReadOnlyBadge from './ReadOnlyBadge'
|
||||
import { useProfile } from '@/lib/profile'
|
||||
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
|
||||
import { isSaveFailed, isSaving } from '@/state/sql-editor/sql-editor-lifecycle'
|
||||
import { isSnippetOwner } from '@/state/sql-editor/sql-editor-rules'
|
||||
|
||||
export type SavingIndicatorProps = { id: string }
|
||||
@@ -14,17 +15,19 @@ const SavingIndicator = ({ id }: SavingIndicatorProps) => {
|
||||
const { profile } = useProfile()
|
||||
const snapV2 = useSqlEditorV2StateSnapshot()
|
||||
|
||||
const savingState = snapV2.savingStates[id]
|
||||
const previousState = usePrevious(savingState)
|
||||
const snippet = snapV2.snippets[id]
|
||||
const status = snippet?.snippet.status
|
||||
const saving = isSaving(status)
|
||||
const saveFailed = isSaveFailed(status)
|
||||
const previousSaving = usePrevious(saving)
|
||||
const [showSavedText, setShowSavedText] = useState(false)
|
||||
|
||||
const snippet = snapV2.snippets[id]
|
||||
const snippetIsOwned = !!snippet && isSnippetOwner(snippet.snippet, profile?.id)
|
||||
|
||||
useEffect(() => {
|
||||
let cancel = false
|
||||
|
||||
if (previousState === 'UPDATING' && savingState === 'IDLE') {
|
||||
if (previousSaving && status === 'saved') {
|
||||
setShowSavedText(true)
|
||||
setTimeout(() => {
|
||||
if (!cancel) setShowSavedText(false)
|
||||
@@ -34,14 +37,14 @@ const SavingIndicator = ({ id }: SavingIndicatorProps) => {
|
||||
return () => {
|
||||
cancel = true
|
||||
}
|
||||
}, [savingState])
|
||||
}, [status, previousSaving])
|
||||
|
||||
const retry = () => snapV2.addNeedsSaving(id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-2 flex items-center gap-2">
|
||||
{snippetIsOwned && savingState === 'UPDATING_FAILED' && (
|
||||
{snippetIsOwned && saveFailed && (
|
||||
<Button
|
||||
variant="text"
|
||||
size="tiny"
|
||||
@@ -58,14 +61,14 @@ const SavingIndicator = ({ id }: SavingIndicatorProps) => {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">All changes saved</TooltipContent>
|
||||
</Tooltip>
|
||||
) : savingState === 'UPDATING' ? (
|
||||
) : saving ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Loader2 className="animate-spin" size={14} strokeWidth={2} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Saving changes...</TooltipContent>
|
||||
</Tooltip>
|
||||
) : savingState === 'UPDATING_FAILED' ? (
|
||||
) : saveFailed ? (
|
||||
snippetIsOwned ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
|
||||
@@ -34,7 +34,7 @@ import { useContentCountQuery } from '@/data/content/content-count-query'
|
||||
import { useContentDeleteMutation } from '@/data/content/content-delete-mutation'
|
||||
import { useSQLSnippetFoldersDeleteMutation } from '@/data/content/sql-folders-delete-mutation'
|
||||
import { Snippet, SnippetFolder, useSQLSnippetFoldersQuery } from '@/data/content/sql-folders-query'
|
||||
import { useSqlSnippetsQuery, type SqlSnippet } from '@/data/content/sql-snippets-query'
|
||||
import { useSqlSnippetsQuery } from '@/data/content/sql-snippets-query'
|
||||
import { useLocalStorage } from '@/hooks/misc/useLocalStorage'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useProfile } from '@/lib/profile'
|
||||
@@ -204,7 +204,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
|
||||
let snippets = favoriteSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? []
|
||||
|
||||
if (snippet && snippet.favorite && !snippets.find((x) => x.id === snippet.id)) {
|
||||
snippets.push(snippet as SqlSnippet)
|
||||
snippets.push(snippet)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -255,7 +255,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
|
||||
let snippets = sharedSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? []
|
||||
|
||||
if (snippet && snippet.visibility === 'project' && !snippets.find((x) => x.id === snippet.id)) {
|
||||
snippets.push(snippet as SqlSnippet)
|
||||
snippets.push(snippet)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -420,7 +420,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
|
||||
useEffect(() => {
|
||||
if (projectRef && privateSnippetsPages?.pages) {
|
||||
privateSnippetsPages.pages.forEach((page) => {
|
||||
page.contents?.forEach((snippet: Snippet) => {
|
||||
page.contents?.forEach((snippet) => {
|
||||
snapV2.addSnippet({ projectRef, snippet })
|
||||
})
|
||||
page.folders?.forEach((folder) => snapV2.addFolder({ projectRef, folder }))
|
||||
|
||||
+3
-7
@@ -29,7 +29,7 @@ import {
|
||||
} from 'ui'
|
||||
|
||||
import { createSqlSnippetSkeletonV2 } from '@/components/interfaces/SQLEditor/SQLEditor.utils'
|
||||
import { getContentById } from '@/data/content/content-id-query'
|
||||
import { getContentById, getSqlSnippetById } from '@/data/content/content-id-query'
|
||||
import { useSQLSnippetFolderContentsQuery } from '@/data/content/sql-folder-contents-query'
|
||||
import { Snippet } from '@/data/content/sql-folders-query'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
@@ -188,12 +188,8 @@ export const SQLEditorTreeViewItem = ({
|
||||
// opened have no content loaded yet, so fetch it before toggling.
|
||||
const storeSnippet = snapV2.snippets[snippetId]
|
||||
if (!storeSnippet?.snippet.content) {
|
||||
const data = await getContentById({ projectRef, id: snippetId })
|
||||
// getContentById returns a union content; narrow to the SQL variant (the
|
||||
// only kind the SQL editor loads) via its discriminating field.
|
||||
if ('unchecked_sql' in data.content) {
|
||||
snapV2.setSnippet(projectRef, { ...data, content: data.content })
|
||||
}
|
||||
const snippet = await getSqlSnippetById({ projectRef, id: snippetId })
|
||||
snapV2.setSnippet(projectRef, snippet)
|
||||
}
|
||||
|
||||
if (isFavorite) snapV2.removeFavorite(snippetId)
|
||||
|
||||
@@ -26,7 +26,8 @@ import {
|
||||
|
||||
import { COMMAND_MENU_SECTIONS } from '@/components/interfaces/App/CommandMenu/CommandMenu.utils'
|
||||
import { orderCommandSectionsByPriority } from '@/components/interfaces/App/CommandMenu/ordering'
|
||||
import { useSqlSnippetsQuery, type SqlSnippet } from '@/data/content/sql-snippets-query'
|
||||
import { type SnippetWithContent } from '@/data/content/sql-folders-query'
|
||||
import { useSqlSnippetsQuery } from '@/data/content/sql-snippets-query'
|
||||
import {
|
||||
useInfiniteTablesQuery,
|
||||
usePrefetchTables,
|
||||
@@ -185,7 +186,7 @@ function SnippetSelector({
|
||||
canCreateNew,
|
||||
}: {
|
||||
projectRef: string | undefined
|
||||
snippets: Array<SqlSnippet> | undefined
|
||||
snippets: Array<SnippetWithContent> | undefined
|
||||
canCreateNew: boolean
|
||||
}) {
|
||||
const router = useRouter()
|
||||
@@ -244,7 +245,7 @@ function SnippetSelector({
|
||||
)
|
||||
}
|
||||
|
||||
function snippetValue(snippet: SqlSnippet) {
|
||||
function snippetValue(snippet: SnippetWithContent) {
|
||||
if (snippet.type !== 'sql') return ''
|
||||
return escapeAttributeSelector(
|
||||
`${snippet.id}-${snippet.name}-${snippet?.content?.unchecked_sql.slice(0, 30)}`
|
||||
|
||||
@@ -4,6 +4,7 @@ import { components } from 'api-types'
|
||||
import type { Content } from './content-query'
|
||||
import { remapSqlContentField } from './content-remap'
|
||||
import { contentKeys } from './keys'
|
||||
import type { SnippetWithContent } from './sql-folders-query'
|
||||
import { get, handleError } from '@/data/fetchers'
|
||||
import type { ResponseError, UseCustomQueryOptions } from '@/types'
|
||||
|
||||
@@ -33,6 +34,43 @@ export async function getContentById(
|
||||
export type ContentIdData = Awaited<ReturnType<typeof getContentById>>
|
||||
export type ContentIdError = ResponseError
|
||||
|
||||
// SQL-editor-specific fetch: the editor only ever loads SQL snippets, so the
|
||||
// content body is typed as SQL content and the result is a SnippetWithContent
|
||||
// (status 'saved') ready to drop into the store — no narrowing/casting at the
|
||||
// call site. Reports etc. keep using the generic getContentById above.
|
||||
export async function getSqlSnippetById(
|
||||
{ projectRef, id }: { projectRef?: string; id?: string },
|
||||
signal?: AbortSignal
|
||||
): Promise<SnippetWithContent> {
|
||||
if (typeof projectRef === 'undefined') throw new Error('projectRef is required')
|
||||
if (typeof id === 'undefined') throw new Error('Content ID is required')
|
||||
|
||||
const { data, error } = await get('/platform/projects/{ref}/content/item/{id}', {
|
||||
params: { path: { ref: projectRef, id } },
|
||||
signal,
|
||||
})
|
||||
|
||||
if (error) throw handleError(error)
|
||||
const snippet = remapSqlContentField(data as unknown as Omit<SnippetWithContent, 'status'>)
|
||||
return { ...snippet, status: 'saved' }
|
||||
}
|
||||
|
||||
export type SqlSnippetByIdData = Awaited<ReturnType<typeof getSqlSnippetById>>
|
||||
|
||||
export const useSqlSnippetByIdQuery = <TData = SqlSnippetByIdData>(
|
||||
{ projectRef, id }: { projectRef?: string; id?: string },
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseCustomQueryOptions<SqlSnippetByIdData, ContentIdError, TData> = {}
|
||||
) =>
|
||||
useQuery<SqlSnippetByIdData, ContentIdError, TData>({
|
||||
queryKey: contentKeys.resource(projectRef, id),
|
||||
queryFn: ({ signal }) => getSqlSnippetById({ projectRef, id }, signal),
|
||||
enabled: enabled && typeof projectRef !== 'undefined' && typeof id !== 'undefined',
|
||||
...options,
|
||||
})
|
||||
|
||||
export const useContentIdQuery = <TData = ContentIdData>(
|
||||
{ projectRef, id }: { projectRef?: string; id?: string },
|
||||
{ enabled = true, ...options }: UseCustomQueryOptions<ContentIdData, ContentIdError, TData> = {}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { toast } from 'sonner'
|
||||
import type { Content } from './content-query'
|
||||
import { unmapSqlContentField } from './content-remap'
|
||||
import { contentKeys } from './keys'
|
||||
import type { Snippet } from './sql-folders-query'
|
||||
import type { Snippet, SnippetWithContent } from './sql-folders-query'
|
||||
import type { components } from '@/data/api'
|
||||
import { handleError, put } from '@/data/fetchers'
|
||||
import type { ResponseError, UseCustomMutationOptions } from '@/types'
|
||||
@@ -23,7 +23,7 @@ export type UpsertContentVariables = {
|
||||
export async function upsertContent(
|
||||
{ projectRef, payload }: UpsertContentVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
): Promise<SnippetWithContent | null> {
|
||||
const { data, error } = await put('/platform/projects/{ref}/content', {
|
||||
params: { path: { ref: projectRef } },
|
||||
body: unmapSqlContentField(payload),
|
||||
@@ -32,7 +32,10 @@ export async function upsertContent(
|
||||
})
|
||||
if (error) handleError(error)
|
||||
|
||||
return data as Snippet | null
|
||||
const snippet = data as Snippet | null
|
||||
// The upsert response is a snippet freshly persisted to the database, so it
|
||||
// carries status 'saved' as it crosses into the app — same as the queries.
|
||||
return snippet === null ? null : { ...snippet, status: 'saved' }
|
||||
}
|
||||
|
||||
export type UpsertContentData = Awaited<ReturnType<typeof upsertContent>>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* The lifecycle of a snippet in the SQL editor, modelled as a single set of
|
||||
* mutually-exclusive states.
|
||||
*
|
||||
* The status is attached to a snippet as it enters the app: 'saved' when
|
||||
* fetched from the database (see the snippet queries in this directory) and
|
||||
* 'new' when created locally (see createSqlSnippetSkeletonV2). The transition
|
||||
* and predicate helpers live in state/sql-editor/sql-editor-lifecycle.
|
||||
*/
|
||||
export type SnippetStatus =
|
||||
// Never persisted to the database (created locally this session):
|
||||
| 'new' // idle, never saved
|
||||
| 'new_saving' // first save in flight
|
||||
| 'new_save_failed' // first save failed
|
||||
// Persisted to the database at least once:
|
||||
| 'saved' // no pending changes
|
||||
| 'unsaved' // has pending local edits
|
||||
| 'saving' // re-save in flight
|
||||
| 'save_failed' // last save failed
|
||||
@@ -1,7 +1,7 @@
|
||||
import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query'
|
||||
|
||||
import { contentKeys } from './keys'
|
||||
import { SNIPPET_PAGE_LIMIT } from './sql-folders-query'
|
||||
import { SNIPPET_PAGE_LIMIT, withSavedStatus } from './sql-folders-query'
|
||||
import { get, handleError } from '@/data/fetchers'
|
||||
import type { ResponseError, UseCustomInfiniteQueryOptions } from '@/types'
|
||||
|
||||
@@ -39,6 +39,7 @@ export async function getSQLSnippetFolderContents(
|
||||
if (error) handleError(error)
|
||||
return {
|
||||
...data.data,
|
||||
contents: (data.data.contents ?? []).map(withSavedStatus),
|
||||
cursor: data.cursor,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { components } from 'api-types'
|
||||
|
||||
import { contentKeys } from './keys'
|
||||
import type { SnippetStatus } from './snippet-status'
|
||||
import { get, handleError } from '@/data/fetchers'
|
||||
import type { ResponseError, UseCustomInfiniteQueryOptions } from '@/types'
|
||||
import type { ResponseError, SqlSnippets, UseCustomInfiniteQueryOptions } from '@/types'
|
||||
|
||||
export type SnippetFolderResponse = components['schemas']['GetUserContentFolderResponse']['data']
|
||||
export type SnippetFolder =
|
||||
@@ -11,6 +12,19 @@ export type SnippetFolder =
|
||||
export type Snippet =
|
||||
components['schemas']['GetUserContentFolderResponse']['data']['contents'][number]
|
||||
|
||||
export interface SnippetWithContent extends Snippet {
|
||||
content?: SqlSnippets.Content
|
||||
status: SnippetStatus
|
||||
}
|
||||
|
||||
// Attaches the 'saved' lifecycle status to a snippet as it crosses from the
|
||||
// database into the app. Generic so it preserves any loaded content on the
|
||||
// snippet, and types `status` as the full SnippetStatus (not the 'saved'
|
||||
// literal) so the result is a regular SnippetWithContent.
|
||||
export function withSavedStatus<T extends Snippet>(snippet: T): T & { status: SnippetStatus } {
|
||||
return { ...snippet, status: 'saved' }
|
||||
}
|
||||
|
||||
export type SQLSnippetFolderVariables = {
|
||||
projectRef?: string
|
||||
cursor?: string
|
||||
@@ -48,6 +62,7 @@ export async function getSQLSnippetFolders(
|
||||
if (error) handleError(error)
|
||||
return {
|
||||
...data.data,
|
||||
contents: (data.data.contents ?? []).map(withSavedStatus),
|
||||
cursor: data.cursor,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { Content } from './content-query'
|
||||
import { remapSqlContentFields } from './content-remap'
|
||||
import { contentKeys } from './keys'
|
||||
import { SNIPPET_PAGE_LIMIT } from './sql-folders-query'
|
||||
import { SNIPPET_PAGE_LIMIT, withSavedStatus, type Snippet } from './sql-folders-query'
|
||||
import { get } from '@/data/fetchers'
|
||||
import { UseCustomInfiniteQueryOptions } from '@/types'
|
||||
import type { SqlSnippets, UseCustomInfiniteQueryOptions } from '@/types'
|
||||
|
||||
export type SqlSnippet = Extract<Content, { type: 'sql' }>
|
||||
|
||||
@@ -49,9 +49,12 @@ export async function getSqlSnippets(
|
||||
throw error
|
||||
}
|
||||
|
||||
const snippets = remapSqlContentFields(
|
||||
data.data as unknown as Array<Snippet & { content?: SqlSnippets.Content }>
|
||||
)
|
||||
return {
|
||||
cursor: data.cursor,
|
||||
contents: remapSqlContentFields(data.data as unknown as SqlSnippet[]),
|
||||
contents: snippets.map(withSavedStatus),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,12 @@ import { EditorBaseLayout } from '@/components/layouts/editors/EditorBaseLayout'
|
||||
import { useEditorType } from '@/components/layouts/editors/EditorsLayout.hooks'
|
||||
import SQLEditorLayout from '@/components/layouts/SQLEditorLayout/SQLEditorLayout'
|
||||
import { SQLEditorMenu } from '@/components/layouts/SQLEditorLayout/SQLEditorMenu'
|
||||
import { useContentIdQuery } from '@/data/content/content-id-query'
|
||||
import { useSqlSnippetByIdQuery } from '@/data/content/content-id-query'
|
||||
import { useDashboardHistory } from '@/hooks/misc/useDashboardHistory'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
import { SnippetWithContent, useSnippets, useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
|
||||
import { useSnippets, useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
|
||||
import { wasNeverPersisted } from '@/state/sql-editor/sql-editor-lifecycle'
|
||||
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
|
||||
import type { NextPageWithLayout } from '@/types'
|
||||
|
||||
@@ -40,9 +41,9 @@ const SqlEditor: NextPageWithLayout = () => {
|
||||
// [Joshen] May need to investigate separately, but occasionally addSnippet doesnt exist in
|
||||
// the snapV2 valtio store for some reason hence why the added typeof check here
|
||||
const canFetchContentBasedOnId = Boolean(
|
||||
id !== 'new' && typeof snapV2.addSnippet === 'function' && !snippet?.isNotSavedInDatabaseYet
|
||||
id !== 'new' && typeof snapV2.addSnippet === 'function' && !wasNeverPersisted(snippet?.status)
|
||||
)
|
||||
const { data, error, isError } = useContentIdQuery(
|
||||
const { data, error, isError } = useSqlSnippetByIdQuery(
|
||||
{ projectRef: ref, id },
|
||||
{
|
||||
retry: false,
|
||||
@@ -60,13 +61,13 @@ const SqlEditor: NextPageWithLayout = () => {
|
||||
// behaviour down to a very specific use case too with all these conditionals
|
||||
// More details: https://github.com/supabase/supabase/pull/39389
|
||||
const snippetMissingImmediatelyAfterCreating =
|
||||
!!snippet && snippetMissing && previousRoute === 'new' && 'isNotSavedInDatabaseYet' in snippet
|
||||
!!snippet && snippetMissing && previousRoute === 'new' && wasNeverPersisted(snippet.status)
|
||||
|
||||
useEffect(() => {
|
||||
if (ref && data && project) {
|
||||
// [Joshen] Check if snippet belongs to the current project
|
||||
if (!IS_PLATFORM || data.project_id === project.id) {
|
||||
snapV2.setSnippet(ref, data as unknown as SnippetWithContent)
|
||||
snapV2.setSnippet(ref, data)
|
||||
} else {
|
||||
setLastVisitedSnippet(undefined)
|
||||
router.replace(`/project/${ref}/sql/new`)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { StateSnippet, StateSnippetFolder, SnippetWithContent } from './sql-editor'
|
||||
export type { StateSnippet, StateSnippetFolder } from './sql-editor'
|
||||
export {
|
||||
sqlEditorState,
|
||||
getSqlEditorV2StateSnapshot,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { StateSnippet, StateSnippetFolder, SnippetWithContent } from './types'
|
||||
export type { StateSnippet, StateSnippetFolder } from './types'
|
||||
export {
|
||||
sqlEditorState,
|
||||
getSqlEditorV2StateSnapshot,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
isSaveFailed,
|
||||
isSaving,
|
||||
statusOnSaveError,
|
||||
statusOnSaveStart,
|
||||
statusOnSaveSuccess,
|
||||
wasNeverPersisted,
|
||||
} from './sql-editor-lifecycle'
|
||||
import type { SnippetStatus } from '@/data/content/snippet-status'
|
||||
|
||||
const NEVER_PERSISTED: Array<SnippetStatus> = ['new', 'new_saving', 'new_save_failed']
|
||||
const PERSISTED: Array<SnippetStatus> = ['saved', 'unsaved', 'saving', 'save_failed']
|
||||
|
||||
describe('wasNeverPersisted', () => {
|
||||
it.each(NEVER_PERSISTED)('is true for never-persisted status %s', (status) => {
|
||||
expect(wasNeverPersisted(status)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(PERSISTED)('is false for persisted status %s', (status) => {
|
||||
expect(wasNeverPersisted(status)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an absent status as persisted', () => {
|
||||
expect(wasNeverPersisted(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSaving', () => {
|
||||
it('is true only while a save is in flight (new or re-save)', () => {
|
||||
expect(isSaving('new_saving')).toBe(true)
|
||||
expect(isSaving('saving')).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['new', 'new_save_failed', 'saved', 'unsaved', 'save_failed', undefined] as const)(
|
||||
'is false for %s',
|
||||
(status) => {
|
||||
expect(isSaving(status)).toBe(false)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('isSaveFailed', () => {
|
||||
it('is true only after a failed save (new or re-save)', () => {
|
||||
expect(isSaveFailed('new_save_failed')).toBe(true)
|
||||
expect(isSaveFailed('save_failed')).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['new', 'new_saving', 'saved', 'unsaved', 'saving', undefined] as const)(
|
||||
'is false for %s',
|
||||
(status) => {
|
||||
expect(isSaveFailed(status)).toBe(false)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('statusOnSaveStart', () => {
|
||||
it('keeps never-persisted snippets in the new family', () => {
|
||||
expect(statusOnSaveStart('new')).toBe('new_saving')
|
||||
expect(statusOnSaveStart('new_save_failed')).toBe('new_saving')
|
||||
})
|
||||
|
||||
it('moves persisted snippets to saving', () => {
|
||||
expect(statusOnSaveStart('saved')).toBe('saving')
|
||||
expect(statusOnSaveStart('unsaved')).toBe('saving')
|
||||
expect(statusOnSaveStart('save_failed')).toBe('saving')
|
||||
expect(statusOnSaveStart(undefined)).toBe('saving')
|
||||
})
|
||||
})
|
||||
|
||||
describe('statusOnSaveSuccess', () => {
|
||||
it('always resolves to saved', () => {
|
||||
expect(statusOnSaveSuccess()).toBe('saved')
|
||||
})
|
||||
})
|
||||
|
||||
describe('statusOnSaveError', () => {
|
||||
it('keeps never-persisted snippets in the new family', () => {
|
||||
expect(statusOnSaveError('new_saving')).toBe('new_save_failed')
|
||||
expect(statusOnSaveError('new')).toBe('new_save_failed')
|
||||
})
|
||||
|
||||
it('moves persisted snippets to save_failed', () => {
|
||||
expect(statusOnSaveError('saving')).toBe('save_failed')
|
||||
expect(statusOnSaveError('saved')).toBe('save_failed')
|
||||
expect(statusOnSaveError(undefined)).toBe('save_failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('lifecycle round trips', () => {
|
||||
it('new snippet: first save succeeds then a re-save succeeds', () => {
|
||||
let status: SnippetStatus = 'new'
|
||||
status = statusOnSaveStart(status)
|
||||
expect(status).toBe('new_saving')
|
||||
status = statusOnSaveSuccess()
|
||||
expect(status).toBe('saved')
|
||||
// re-save
|
||||
status = statusOnSaveStart(status)
|
||||
expect(status).toBe('saving')
|
||||
status = statusOnSaveSuccess()
|
||||
expect(status).toBe('saved')
|
||||
})
|
||||
|
||||
it('new snippet: first save fails, retry succeeds (stays never-persisted until success)', () => {
|
||||
let status: SnippetStatus = 'new'
|
||||
status = statusOnSaveStart(status)
|
||||
status = statusOnSaveError(status)
|
||||
expect(status).toBe('new_save_failed')
|
||||
expect(wasNeverPersisted(status)).toBe(true)
|
||||
// retry
|
||||
status = statusOnSaveStart(status)
|
||||
expect(status).toBe('new_saving')
|
||||
status = statusOnSaveSuccess()
|
||||
expect(status).toBe('saved')
|
||||
expect(wasNeverPersisted(status)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { SnippetStatus } from '@/data/content/snippet-status'
|
||||
|
||||
/**
|
||||
* True when the snippet has never been successfully written to the database.
|
||||
* Gates content fetching and list invalidation, and marks locally-created
|
||||
* snippets for the replication-lag 404 swallow in the `[id]` page.
|
||||
*/
|
||||
export function wasNeverPersisted(status: SnippetStatus | undefined): boolean {
|
||||
return status === 'new' || status === 'new_saving' || status === 'new_save_failed'
|
||||
}
|
||||
|
||||
/** True while a save request is in flight (whether first save or re-save). */
|
||||
export function isSaving(status: SnippetStatus | undefined): boolean {
|
||||
return status === 'new_saving' || status === 'saving'
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the most recent save attempt failed (whether first save or
|
||||
* re-save).
|
||||
*/
|
||||
export function isSaveFailed(status: SnippetStatus | undefined): boolean {
|
||||
return status === 'new_save_failed' || status === 'save_failed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition when a save request begins, preserving the never-persisted axis.
|
||||
*/
|
||||
export function statusOnSaveStart(status: SnippetStatus | undefined): SnippetStatus {
|
||||
return wasNeverPersisted(status) ? 'new_saving' : 'saving'
|
||||
}
|
||||
|
||||
/** Transition when a save succeeds — the snippet is now persisted and clean. */
|
||||
export function statusOnSaveSuccess(): SnippetStatus {
|
||||
return 'saved'
|
||||
}
|
||||
|
||||
/** Transition when a save fails, preserving the never-persisted axis. */
|
||||
export function statusOnSaveError(status: SnippetStatus | undefined): SnippetStatus {
|
||||
return wasNeverPersisted(status) ? 'new_save_failed' : 'save_failed'
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
validateMoveToFolder,
|
||||
type LoadedSnippet,
|
||||
} from './sql-editor-rules'
|
||||
import type { SnippetWithContent } from './types'
|
||||
import type { SnippetWithContent } from '@/data/content/sql-folders-query'
|
||||
|
||||
function makeSnippet(overrides: Omit<Partial<SnippetWithContent>, 'content'> = {}): LoadedSnippet {
|
||||
return {
|
||||
@@ -22,6 +22,7 @@ function makeSnippet(overrides: Omit<Partial<SnippetWithContent>, 'content'> = {
|
||||
owner_id: 7,
|
||||
folder_id: null,
|
||||
favorite: false,
|
||||
status: 'saved',
|
||||
inserted_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SnippetWithContent } from './types'
|
||||
import type { UpsertContentPayload } from '@/data/content/content-upsert-mutation'
|
||||
import type { SnippetWithContent } from '@/data/content/sql-folders-query'
|
||||
|
||||
/**
|
||||
*
|
||||
|
||||
@@ -5,14 +5,16 @@ import { toast } from 'sonner'
|
||||
import { proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio'
|
||||
import { devtools, proxyMap } from 'valtio/utils'
|
||||
|
||||
import { statusOnSaveError, statusOnSaveStart, statusOnSaveSuccess } from './sql-editor-lifecycle'
|
||||
import { buildUpsertPayload, isLoadedSnippet, validateMoveToFolder } from './sql-editor-rules'
|
||||
import type { SnippetWithContent, StateSnippet, StateSnippetFolder } from './types'
|
||||
import type { 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 type { SnippetWithContent } from '@/data/content/sql-folders-query'
|
||||
import { Snippet, SnippetFolder } from '@/data/content/sql-folders-query'
|
||||
import { getQueryClient } from '@/data/query-client'
|
||||
|
||||
@@ -64,12 +66,6 @@ export const sqlEditorState = proxy({
|
||||
* 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.
|
||||
@@ -107,7 +103,6 @@ export const sqlEditorState = proxy({
|
||||
sqlEditorState.snippets[snippet.id] = { projectRef, splitSizes: [50, 50], snippet }
|
||||
sqlEditorState.results[snippet.id] = []
|
||||
sqlEditorState.explainResults[snippet.id] = { rows: [] }
|
||||
sqlEditorState.savingStates[snippet.id] = 'IDLE'
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -387,8 +382,9 @@ async function upsertSnippet(
|
||||
payload: UpsertContentPayload,
|
||||
shouldInvalidate = false
|
||||
) {
|
||||
const snippet = sqlEditorState.snippets[id]?.snippet
|
||||
try {
|
||||
sqlEditorState.savingStates[id] = 'UPDATING'
|
||||
if (snippet) snippet.status = statusOnSaveStart(snippet.status)
|
||||
await upsertContent({ projectRef, payload })
|
||||
|
||||
if (shouldInvalidate) {
|
||||
@@ -400,13 +396,9 @@ async function upsertSnippet(
|
||||
])
|
||||
}
|
||||
|
||||
let snippet = sqlEditorState.snippets[id]?.snippet
|
||||
if (snippet?.content && 'isNotSavedInDatabaseYet' in snippet) {
|
||||
snippet.isNotSavedInDatabaseYet = false
|
||||
}
|
||||
sqlEditorState.savingStates[id] = 'IDLE'
|
||||
if (snippet) snippet.status = statusOnSaveSuccess()
|
||||
} catch (error) {
|
||||
sqlEditorState.savingStates[id] = 'UPDATING_FAILED'
|
||||
if (snippet) snippet.status = statusOnSaveError(snippet.status)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Snippet, SnippetFolder } from '@/data/content/sql-folders-query'
|
||||
import type { SqlSnippets } from '@/types'
|
||||
import type { SnippetFolder, SnippetWithContent } from '@/data/content/sql-folders-query'
|
||||
|
||||
export type StateSnippetFolder = {
|
||||
projectRef: string
|
||||
@@ -12,9 +11,3 @@ export type StateSnippet = {
|
||||
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