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 && (