From cc5548dbe0b4083794fa1c2c67fd976d6732fecf Mon Sep 17 00:00:00 2001 From: Charis Lam <26616127+charislam@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:50:37 -0400 Subject: [PATCH] refactor(studio): unify snippet save + persistence into SnippetStatus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../interfaces/SQLEditor/MonacoEditor.tsx | 3 +- .../interfaces/SQLEditor/MoveQueryModal.tsx | 7 +- .../interfaces/SQLEditor/RenameQueryModal.tsx | 7 +- .../interfaces/SQLEditor/SQLEditor.utils.ts | 4 +- .../UtilityPanel/SavingIndicator.tsx | 19 +-- .../SQLEditorNavV2/SQLEditorNav.tsx | 8 +- .../SQLEditorNavV2/SQLEditorTreeViewItem.tsx | 10 +- .../SQLEditorLayout/SqlEditor.Commands.tsx | 7 +- apps/studio/data/content/content-id-query.ts | 38 ++++++ .../data/content/content-upsert-mutation.ts | 9 +- apps/studio/data/content/snippet-status.ts | 19 +++ .../data/content/sql-folder-contents-query.ts | 3 +- apps/studio/data/content/sql-folders-query.ts | 17 ++- .../studio/data/content/sql-snippets-query.ts | 9 +- apps/studio/pages/project/[ref]/sql/[id].tsx | 13 +- apps/studio/state/sql-editor-v2.ts | 2 +- apps/studio/state/sql-editor/index.ts | 2 +- .../sql-editor/sql-editor-lifecycle.test.ts | 118 ++++++++++++++++++ .../state/sql-editor/sql-editor-lifecycle.ts | 40 ++++++ .../state/sql-editor/sql-editor-rules.test.ts | 3 +- .../state/sql-editor/sql-editor-rules.ts | 2 +- .../state/sql-editor/sql-editor-state.ts | 22 ++-- apps/studio/state/sql-editor/types.ts | 9 +- 23 files changed, 297 insertions(+), 74 deletions(-) create mode 100644 apps/studio/data/content/snippet-status.ts create mode 100644 apps/studio/state/sql-editor/sql-editor-lifecycle.test.ts create mode 100644 apps/studio/state/sql-editor/sql-editor-lifecycle.ts diff --git a/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx index dc954c5088a..a44e6a8b63c 100644 --- a/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx @@ -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 diff --git a/apps/studio/components/interfaces/SQLEditor/MoveQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/MoveQueryModal.tsx index 7fd3ea7ae7e..af39f60b7ea 100644 --- a/apps/studio/components/interfaces/SQLEditor/MoveQueryModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/MoveQueryModal.tsx @@ -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 { diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx index fa5da761acf..1f92fc58090 100644 --- a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx @@ -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({ diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts index 0e497cca467..9e01e8c24a2 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts @@ -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', } } diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SavingIndicator.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SavingIndicator.tsx index 12c34a4214d..dd62160f012 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SavingIndicator.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SavingIndicator.tsx @@ -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 ( <>
- {snippetIsOwned && savingState === 'UPDATING_FAILED' && ( + {snippetIsOwned && saveFailed && (