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:
Charis Lam committed 2026-06-23 18:50:55 -04:00
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 }))
@@ -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,
}
}
+16 -1
View File
@@ -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),
}
}
+7 -6
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
export type { StateSnippet, StateSnippetFolder, SnippetWithContent } from './sql-editor'
export type { StateSnippet, StateSnippetFolder } from './sql-editor'
export {
sqlEditorState,
getSqlEditorV2StateSnapshot,
+1 -1
View File
@@ -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 -8
View File
@@ -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
}