From 5cb81123ae63bdbcefc710ce32a33ee95e21bcd5 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:24:04 -0400 Subject: [PATCH] refactor(studio): move SQL editor save trigger into a scheduler + provider (5/9) (#47316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What PR 5 of a stacked refactor. Moves *when to save* out of a module-load `subscribe` and into an injectable **scheduler** armed by a headless **provider**, splits the save queue, and adds an unsaved-close warning. ### Scheduler (`sql-editor-save-scheduler.ts`) `createSaveScheduler({ state, saveMechanism, notify, getSaveMode })` owns the save *policy*: - **auto** mode drains the dirty snippet queue as edits land; **manual** mode (the seam for a future opt-in; defaults to `auto`) leaves snippets queued until `requestSave`. Folder saves always drain. - `start()` returns an unsubscribe; `requestSave(id)` is the explicit-save entry. ### Provider (`sql-editor-save-coordinator.tsx`) Headless `SqlEditorSaveCoordinatorProvider` instantiates the mechanism (invalidation via the **React Query client from context**, not the global `getQueryClient`) + scheduler, `start()`s it in an effect (start/stop with the provider), and exposes `requestSave` via `useSqlEditorSaveCoordinator()`. Mounted in `ProjectContext` (under the app's QueryClientProvider). Cmd+S and the SavingIndicator Retry now go through `requestSave`. ### Queue split `needsSaving` (snippets) and `pendingFolderSaves` (folders) are separate queues, drained independently — the old snippet-vs-folder `if/else` is gone. ### Unsaved-close warning A `beforeunload` guard triggers the browser's native "Leave site?" prompt while any snippet's `status !== 'saved'` (failed / in-flight / never-saved). ## Summary by CodeRabbit * **New Features** * Improved SQL editor saving with a centralized save flow, including automatic/manual save handling and immediate “Save Query” requests. * Added unsaved-change detection so the app can warn before closing or reloading when edits are still pending. * **Bug Fixes** * Retry actions now use the updated save flow for more reliable re-saving. --- .../interfaces/SQLEditor/MonacoEditor.tsx | 7 +- .../UtilityPanel/SavingIndicator.tsx | 4 +- .../layouts/ProjectLayout/ProjectContext.tsx | 3 +- .../sql-editor/sql-editor-lifecycle.test.ts | 15 ++ .../state/sql-editor/sql-editor-lifecycle.ts | 9 + .../state/sql-editor/sql-editor-rules.ts | 2 +- .../sql-editor-save-coordinator.tsx | 83 ++++++++ .../sql-editor-save-scheduler.test.ts | 194 ++++++++++++++++++ .../sql-editor/sql-editor-save-scheduler.ts | 111 ++++++++++ .../state/sql-editor/sql-editor-state.ts | 71 +------ 10 files changed, 433 insertions(+), 66 deletions(-) create mode 100644 apps/studio/state/sql-editor/sql-editor-save-coordinator.tsx create mode 100644 apps/studio/state/sql-editor/sql-editor-save-scheduler.test.ts create mode 100644 apps/studio/state/sql-editor/sql-editor-save-scheduler.ts diff --git a/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx index a44e6a8b63c..7080fcd8a9f 100644 --- a/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx @@ -21,6 +21,7 @@ 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 { useSqlEditorSaveCoordinator } from '@/state/sql-editor/sql-editor-save-coordinator' import { useTabsStateSnapshot } from '@/state/tabs' export type MonacoEditorProps = { @@ -93,6 +94,10 @@ export const MonacoEditor = ({ const aiHotkeyEnabledRef = useRef(isAIAssistantHotkeyEnabled) aiHotkeyEnabledRef.current = isAIAssistantHotkeyEnabled + const { requestSave } = useSqlEditorSaveCoordinator() + const requestSaveRef = useRef(requestSave) + requestSaveRef.current = requestSave + const handleEditorOnMount: OnMount = (editor, monaco) => { const model = editor.getModel() if (model !== null) { @@ -142,7 +147,7 @@ export const MonacoEditor = ({ contextMenuGroupId: 'operation', contextMenuOrder: 0, run: () => { - if (snippet) snapV2.addNeedsSaving(snippet.snippet.id) + if (snippet) requestSaveRef.current(snippet.snippet.id) }, }) diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SavingIndicator.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SavingIndicator.tsx index dd62160f012..9f3e98ce218 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SavingIndicator.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SavingIndicator.tsx @@ -8,6 +8,7 @@ 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' +import { useSqlEditorSaveCoordinator } from '@/state/sql-editor/sql-editor-save-coordinator' export type SavingIndicatorProps = { id: string } @@ -23,6 +24,7 @@ const SavingIndicator = ({ id }: SavingIndicatorProps) => { const [showSavedText, setShowSavedText] = useState(false) const snippetIsOwned = !!snippet && isSnippetOwner(snippet.snippet, profile?.id) + const { requestSave } = useSqlEditorSaveCoordinator() useEffect(() => { let cancel = false @@ -39,7 +41,7 @@ const SavingIndicator = ({ id }: SavingIndicatorProps) => { } }, [status, previousSaving]) - const retry = () => snapV2.addNeedsSaving(id) + const retry = () => requestSave(id) return ( <> diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectContext.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectContext.tsx index 1365e8ef4de..d4ecdd8c26b 100644 --- a/apps/studio/components/layouts/ProjectLayout/ProjectContext.tsx +++ b/apps/studio/components/layouts/ProjectLayout/ProjectContext.tsx @@ -2,6 +2,7 @@ import { PropsWithChildren } from 'react' import { DatabaseSelectorStateContextProvider } from '@/state/database-selector' import { RoleImpersonationStateContextProvider } from '@/state/role-impersonation-state' +import { SqlEditorSaveCoordinatorProvider } from '@/state/sql-editor/sql-editor-save-coordinator' import { TableEditorStateContextProvider } from '@/state/table-editor' import { TabsStateContextProvider } from '@/state/tabs' @@ -18,7 +19,7 @@ export const ProjectContextProvider = ({ - {children} + {children} diff --git a/apps/studio/state/sql-editor/sql-editor-lifecycle.test.ts b/apps/studio/state/sql-editor/sql-editor-lifecycle.test.ts index 479a43c706c..45419f50b7b 100644 --- a/apps/studio/state/sql-editor/sql-editor-lifecycle.test.ts +++ b/apps/studio/state/sql-editor/sql-editor-lifecycle.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { folderStatusOnSaveStart, + hasUnsavedChanges, isFolderEditing, isFolderSaving, isNewFolder, @@ -60,6 +61,20 @@ describe('isSaveFailed', () => { ) }) +describe('hasUnsavedChanges', () => { + it('is false only for a clean, saved snippet', () => { + expect(hasUnsavedChanges('saved')).toBe(false) + expect(hasUnsavedChanges(undefined)).toBe(false) + }) + + it.each(['new', 'new_saving', 'new_save_failed', 'unsaved', 'saving', 'save_failed'] as const)( + 'is true for unsaved/in-flight/failed status %s', + (status) => { + expect(hasUnsavedChanges(status)).toBe(true) + } + ) +}) + describe('statusOnSaveStart', () => { it('keeps never-persisted snippets in the new family', () => { expect(statusOnSaveStart('new')).toBe('new_saving') diff --git a/apps/studio/state/sql-editor/sql-editor-lifecycle.ts b/apps/studio/state/sql-editor/sql-editor-lifecycle.ts index 24fc6258e2a..cc3013aae78 100644 --- a/apps/studio/state/sql-editor/sql-editor-lifecycle.ts +++ b/apps/studio/state/sql-editor/sql-editor-lifecycle.ts @@ -22,6 +22,15 @@ export function isSaveFailed(status: SnippetStatus | undefined): boolean { return status === 'new_save_failed' || status === 'save_failed' } +/** + * True when the snippet holds changes that are not safely persisted: never + * saved ('new' family), a save in flight, a failed save, or pending local edits + * ('unsaved'). Used to warn before the tab is closed. Only 'saved' is clean. + */ +export function hasUnsavedChanges(status: SnippetStatus | undefined): boolean { + return status !== undefined && status !== 'saved' +} + /** * Transition when a save request begins, preserving the never-persisted axis. */ diff --git a/apps/studio/state/sql-editor/sql-editor-rules.ts b/apps/studio/state/sql-editor/sql-editor-rules.ts index 063ff6f5b67..b1c52682d28 100644 --- a/apps/studio/state/sql-editor/sql-editor-rules.ts +++ b/apps/studio/state/sql-editor/sql-editor-rules.ts @@ -32,7 +32,7 @@ export function validateMoveToFolder({ }: { visibility?: SnippetWithContent['visibility'] folderId?: string | null -}): { ok: boolean; error?: string } { +}): { ok: true } | { ok: false; error: string } { if (visibility === 'project' && !!folderId) { return { ok: false, error: 'Shared snippet cannot be within a folder' } } diff --git a/apps/studio/state/sql-editor/sql-editor-save-coordinator.tsx b/apps/studio/state/sql-editor/sql-editor-save-coordinator.tsx new file mode 100644 index 00000000000..a582e91753f --- /dev/null +++ b/apps/studio/state/sql-editor/sql-editor-save-coordinator.tsx @@ -0,0 +1,83 @@ +import { useQueryClient } from '@tanstack/react-query' +import { createContext, useContext, useEffect, useMemo, type PropsWithChildren } from 'react' +import { toast } from 'sonner' + +import { hasUnsavedChanges } from './sql-editor-lifecycle' +import { createSaveMechanism } from './sql-editor-save' +import { createSaveScheduler, type SaveScheduler } from './sql-editor-save-scheduler' +import { sqlEditorState } from './sql-editor-state' +import { upsertContent } 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' + +type SaveCoordinator = Pick + +const SqlEditorSaveCoordinatorContext = createContext(null) + +/** + * Wires the SQL editor save mechanism + scheduler and arms the auto-save trigger + * for as long as it's mounted. Lives here (rather than at module load) so that + * query invalidation uses the React Query client from context, and the + * subscription is started/stopped deterministically — and is testable. + * + * Exposes `requestSave` (the explicit-save entry, e.g. Cmd+S) via context. + */ +export function SqlEditorSaveCoordinatorProvider({ children }: PropsWithChildren) { + const queryClient = useQueryClient() + + const scheduler = useMemo(() => { + const mechanism = createSaveMechanism({ + state: sqlEditorState, + upsertContent, + createSQLSnippetFolder, + updateSQLSnippetFolder, + notify: toast, + invalidate: async (projectRef: string) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: contentKeys.count(projectRef, 'sql') }), + queryClient.invalidateQueries({ queryKey: contentKeys.sqlSnippets(projectRef) }), + queryClient.invalidateQueries({ queryKey: contentKeys.folders(projectRef) }), + ]) + }, + }) + // getSaveMode defaults to 'auto'; the manual-save opt-in plugs in here later. + return createSaveScheduler({ state: sqlEditorState, saveMechanism: mechanism, notify: toast }) + }, [queryClient]) + + useEffect(() => scheduler.start(), [scheduler]) + + // Warn before the tab is closed/reloaded while any snippet still has unsaved + // work (a failed save, a save in flight, or a never-saved snippet). In-app + // navigation isn't guarded — the store survives client-side route changes, so + // nothing is lost. Browsers only allow the native prompt here, not a custom one. + useEffect(() => { + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + const hasUnsaved = Object.values(sqlEditorState.snippets).some((stateSnippet) => + hasUnsavedChanges(stateSnippet.snippet.status) + ) + if (hasUnsaved) { + event.preventDefault() + event.returnValue = true + } + } + window.addEventListener('beforeunload', handleBeforeUnload) + return () => window.removeEventListener('beforeunload', handleBeforeUnload) + }, []) + + return ( + + {children} + + ) +} + +export function useSqlEditorSaveCoordinator() { + const coordinator = useContext(SqlEditorSaveCoordinatorContext) + if (coordinator === null) { + throw new Error( + 'useSqlEditorSaveCoordinator must be used within a SqlEditorSaveCoordinatorProvider' + ) + } + return coordinator +} diff --git a/apps/studio/state/sql-editor/sql-editor-save-scheduler.test.ts b/apps/studio/state/sql-editor/sql-editor-save-scheduler.test.ts new file mode 100644 index 00000000000..00dc272a804 --- /dev/null +++ b/apps/studio/state/sql-editor/sql-editor-save-scheduler.test.ts @@ -0,0 +1,194 @@ +import { untrustedSql } from '@supabase/pg-meta' +import { proxy } from 'valtio' +import { proxyMap } from 'valtio/utils' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createSaveScheduler, type SaveMode } from './sql-editor-save-scheduler' +import type { StateSnippet, StateSnippetFolder } from './types' + +// Valtio notifies subscribers on a microtask; flush past it before asserting. +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +function makeStateSnippet(overrides: Partial = {}): StateSnippet { + return { + projectRef: 'ref', + splitSizes: [50, 50], + snippet: { + id: 'snippet-1', + type: 'sql', + name: 'Query', + description: '', + visibility: 'user', + project_id: 1, + owner_id: 1, + folder_id: null, + favorite: false, + inserted_at: '2024-01-01T00:00:00.000Z', + updated_at: '2024-01-01T00:00:00.000Z', + status: 'saved', + content: { content_id: 'snippet-1', schema_version: '1', unchecked_sql: untrustedSql('x') }, + ...overrides, + }, + } +} + +function makeStateFolder( + status: StateSnippetFolder['status'], + name = 'Folder' +): StateSnippetFolder { + return { + projectRef: 'ref', + status, + folder: { id: 'f1', name, owner_id: 1, project_id: 1, parent_id: null }, + } +} + +function setup(mode: SaveMode = 'auto') { + const state = proxy({ + needsSaving: proxyMap(), + pendingFolderSaves: proxyMap(), + snippets: {} as Record, + folders: {} as Record, + }) + + const saveMechanism = { saveSnippet: vi.fn(), createFolder: vi.fn(), updateFolder: vi.fn() } + const notify = { success: vi.fn(), error: vi.fn() } + let saveMode = mode + + const scheduler = createSaveScheduler({ + state, + saveMechanism, + notify, + getSaveMode: () => saveMode, + }) + + return { state, saveMechanism, notify, scheduler, setMode: (m: SaveMode) => (saveMode = m) } +} + +describe('save scheduler — auto mode', () => { + let stop: () => void = () => {} + afterEach(() => stop()) + + it('drains the snippet queue, saving each with its shouldInvalidate flag', async () => { + const t = setup('auto') + stop = t.scheduler.start() + + t.state.snippets['snippet-1'] = makeStateSnippet() + t.state.needsSaving.set('snippet-1', true) + await flush() + + expect(t.saveMechanism.saveSnippet).toHaveBeenCalledWith({ + id: 'snippet-1', + projectRef: 'ref', + shouldInvalidate: true, + }) + expect(t.state.needsSaving.has('snippet-1')).toBe(false) // dequeued + }) + + it('dispatches each queued snippet exactly once (claim-first; no double save)', async () => { + const t = setup('auto') + stop = t.scheduler.start() + + t.state.snippets['a'] = makeStateSnippet({ id: 'a' }) + t.state.snippets['b'] = makeStateSnippet({ id: 'b' }) + t.state.needsSaving.set('a', false) + t.state.needsSaving.set('b', false) + await flush() + + expect(t.saveMechanism.saveSnippet).toHaveBeenCalledTimes(2) + expect(t.state.needsSaving.size).toBe(0) + }) + + it('does not save a shared snippet that would land in a folder, and surfaces an error', async () => { + const t = setup('auto') + stop = t.scheduler.start() + + t.state.snippets['snippet-1'] = makeStateSnippet({ + visibility: 'project', + folder_id: 'folder-1', + }) + t.state.needsSaving.set('snippet-1', false) + await flush() + + expect(t.saveMechanism.saveSnippet).not.toHaveBeenCalled() + expect(t.notify.error).toHaveBeenCalledWith('Shared snippet cannot be within a folder') + }) + + it('routes new folders to createFolder and persisted folders to updateFolder', async () => { + const t = setup('auto') + stop = t.scheduler.start() + + t.state.folders['f1'] = makeStateFolder('new_saving') + t.state.pendingFolderSaves.set('f1', true) + await flush() + expect(t.saveMechanism.createFolder).toHaveBeenCalledWith({ + projectRef: 'ref', + name: 'Folder', + placeholderId: 'f1', + }) + + t.state.folders['f1'] = makeStateFolder('saving') + t.state.pendingFolderSaves.set('f1', true) + await flush() + expect(t.saveMechanism.updateFolder).toHaveBeenCalledWith({ + id: 'f1', + projectRef: 'ref', + name: 'Folder', + }) + }) + + it('stops saving after the subscription is torn down', async () => { + const t = setup('auto') + const teardown = t.scheduler.start() + teardown() + + t.state.snippets['snippet-1'] = makeStateSnippet() + t.state.needsSaving.set('snippet-1', true) + await flush() + + expect(t.saveMechanism.saveSnippet).not.toHaveBeenCalled() + }) +}) + +describe('save scheduler — manual mode', () => { + let stop: () => void = () => {} + afterEach(() => stop()) + + it('leaves edited snippets queued instead of auto-saving', async () => { + const t = setup('manual') + stop = t.scheduler.start() + + t.state.snippets['snippet-1'] = makeStateSnippet() + t.state.needsSaving.set('snippet-1', true) + await flush() + + expect(t.saveMechanism.saveSnippet).not.toHaveBeenCalled() + expect(t.state.needsSaving.has('snippet-1')).toBe(true) // still dirty + }) + + it('still persists folder creates/renames (not gated by save mode)', async () => { + const t = setup('manual') + stop = t.scheduler.start() + + t.state.folders['f1'] = makeStateFolder('saving') + t.state.pendingFolderSaves.set('f1', true) + await flush() + + expect(t.saveMechanism.updateFolder).toHaveBeenCalled() + }) +}) + +describe('save scheduler — requestSave', () => { + it('saves immediately regardless of mode', () => { + const t = setup('manual') + t.state.snippets['snippet-1'] = makeStateSnippet() + + t.scheduler.requestSave('snippet-1') + + expect(t.saveMechanism.saveSnippet).toHaveBeenCalledWith({ + id: 'snippet-1', + projectRef: 'ref', + shouldInvalidate: true, + }) + }) +}) diff --git a/apps/studio/state/sql-editor/sql-editor-save-scheduler.ts b/apps/studio/state/sql-editor/sql-editor-save-scheduler.ts new file mode 100644 index 00000000000..ef927accb44 --- /dev/null +++ b/apps/studio/state/sql-editor/sql-editor-save-scheduler.ts @@ -0,0 +1,111 @@ +import { subscribe } from 'valtio' + +import { isNewFolder } from './sql-editor-lifecycle' +import { validateMoveToFolder } from './sql-editor-rules' +import type { SaveMechanism } from './sql-editor-save' +import type { StateSnippet, StateSnippetFolder } from './types' +import type { Notifier } from '@/lib/notifier' + +/** + * Whether snippet edits persist on their own ('auto') or only when the user + * asks ('manual'). Only 'auto' is wired today; 'manual' is the seam for a future + * opt-in. Folder creates/renames are explicit user actions and always persist. + */ +export type SaveMode = 'auto' | 'manual' + +/** The slice of the store the scheduler watches and reads. */ +export interface SaveSchedulerStore { + /** Snippet ids queued for save; value is whether to invalidate the lists. */ + needsSaving: Map + /** Folder ids queued for save (create or rename). */ + pendingFolderSaves: Map + snippets: { [id: string]: StateSnippet | undefined } + folders: { [id: string]: StateSnippetFolder | undefined } +} + +export interface SaveSchedulerDeps { + state: SaveSchedulerStore + saveMechanism: Pick + notify: Notifier + /** Resolves the current save mode. Defaults to 'auto'. */ + getSaveMode?: () => SaveMode +} + +/** + * The save *scheduler*: it owns the policy of *when* the save mechanism runs. + * In 'auto' mode it drains the dirty snippet queue as edits land; in 'manual' + * mode it leaves snippets queued until `requestSave` is called. Folder saves are + * always drained. This is the unit that the headless provider `start()`s; it is + * decoupled from React so it can be exercised directly. + */ +export function createSaveScheduler({ + state, + saveMechanism, + notify, + getSaveMode = () => 'auto', +}: SaveSchedulerDeps) { + function flushSnippet(id: string, shouldInvalidate: boolean) { + const stateSnippet = state.snippets[id] + if (stateSnippet === undefined) return + + const { visibility, folder_id } = stateSnippet.snippet + const moveCheck = validateMoveToFolder({ visibility, folderId: folder_id }) + if (!moveCheck.ok) { + notify.error(moveCheck.error) + return + } + + saveMechanism.saveSnippet({ id, projectRef: stateSnippet.projectRef, shouldInvalidate }) + } + + function flushFolder(id: string) { + const stateFolder = state.folders[id] + if (stateFolder === undefined) return + + const { projectRef, folder, status } = stateFolder + if (isNewFolder(status)) { + saveMechanism.createFolder({ projectRef, name: folder.name, placeholderId: id }) + } else { + saveMechanism.updateFolder({ id, projectRef, name: folder.name }) + } + } + + function drainSnippetQueue() { + for (const [id, shouldInvalidate] of Array.from(state.needsSaving.entries())) { + state.needsSaving.delete(id) + flushSnippet(id, shouldInvalidate) + } + } + + function drainFolderQueue() { + for (const [id] of Array.from(state.pendingFolderSaves.entries())) { + state.pendingFolderSaves.delete(id) + flushFolder(id) + } + } + + function start() { + const unsubscribeSnippets = subscribe(state.needsSaving, () => { + // In manual mode, edits stay queued until an explicit requestSave. + if (getSaveMode() !== 'auto') return + drainSnippetQueue() + }) + const unsubscribeFolders = subscribe(state.pendingFolderSaves, () => { + drainFolderQueue() + }) + return () => { + unsubscribeSnippets() + unsubscribeFolders() + } + } + + /** Explicit save (e.g. Cmd+S / retry): persist now regardless of save mode. */ + function requestSave(id: string) { + state.needsSaving.delete(id) + flushSnippet(id, true) + } + + return { start, requestSave } +} + +export type SaveScheduler = ReturnType diff --git a/apps/studio/state/sql-editor/sql-editor-state.ts b/apps/studio/state/sql-editor/sql-editor-state.ts index 975b057bc3d..93c785d2e16 100644 --- a/apps/studio/state/sql-editor/sql-editor-state.ts +++ b/apps/studio/state/sql-editor/sql-editor-state.ts @@ -1,22 +1,15 @@ import { untrustedSql } from '@supabase/pg-meta' import { useMemo } from 'react' import { toast } from 'sonner' -import { proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio' +import { proxy, ref, snapshot, useSnapshot } from 'valtio' import { devtools, proxyMap } from 'valtio/utils' import { folderStatusOnSaveStart, isNewFolder } from './sql-editor-lifecycle' -import { validateMoveToFolder } from './sql-editor-rules' -import { createSaveMechanism } from './sql-editor-save' 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 } 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' export const sqlEditorState = proxy({ // ======================================================================== @@ -61,9 +54,15 @@ export const sqlEditorState = proxy({ } }, /** - * Synchronous saving of folders and snippets (debounce behavior). Key is the snippet id, value is shouldInvalidate + * Snippets queued for saving. Key is the snippet id, value is whether saving + * it should also invalidate the snippet/folder lists. */ needsSaving: proxyMap([]), + /** + * Folders queued for saving (create or rename). Kept separate from + * `needsSaving` so snippet and folder saves are scheduled independently. + */ + pendingFolderSaves: proxyMap([]), /** * 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. @@ -253,7 +252,7 @@ export const sqlEditorState = proxy({ if (hasChanges) { // Remember this folder's own pre-rename name so a failed save can roll back. storeFolder.previousName = originalFolderName - sqlEditorState.needsSaving.set(id, true) + sqlEditorState.pendingFolderSaves.set(id, true) } }, @@ -370,62 +369,10 @@ export const useSnippets = (projectRef: string) => { ) } -// ======================================================================== -// ## Below are all the asynchronous saving logic for the SQL Editor -// ======================================================================== - -// The save mechanism owns how snippets/folders are persisted. Its data-layer -// collaborators and query invalidation are injected here; the subscribe below -// decides what to enqueue. (The enqueue policy moves to a scheduler in a later PR.) -const saveMechanism = createSaveMechanism({ - state: sqlEditorState, - upsertContent, - createSQLSnippetFolder, - updateSQLSnippetFolder, - notify: toast, - invalidate: async (projectRef: string) => { - const queryClient = getQueryClient() - await Promise.all([ - queryClient.invalidateQueries({ queryKey: contentKeys.count(projectRef, 'sql') }), - queryClient.invalidateQueries({ queryKey: contentKeys.sqlSnippets(projectRef) }), - queryClient.invalidateQueries({ queryKey: contentKeys.folders(projectRef) }), - ]) - }, -}) - if (typeof window !== 'undefined') { devtools(sqlEditorState, { name: 'sqlEditorStateV2', // [Joshen] So that jest unit tests can ignore this enabled: process.env.NEXT_PUBLIC_ENVIRONMENT !== undefined, }) - - subscribe(sqlEditorState.needsSaving, () => { - const state = getSqlEditorV2StateSnapshot() - - state.needsSaving.forEach((shouldInvalidate, id) => { - const snippet = state.snippets[id] - const folder = state.folders[id] - - if (snippet) { - const { visibility, folder_id } = snippet.snippet - const result = validateMoveToFolder({ visibility, folderId: folder_id }) - - if (!result.ok) { - toast.error(result.error) - } else { - saveMechanism.saveSnippet({ id, projectRef: snippet.projectRef, shouldInvalidate }) - sqlEditorState.needsSaving.delete(id) - } - } else if (folder) { - const { projectRef, folder: folderData, status } = folder - if (isNewFolder(status)) { - saveMechanism.createFolder({ projectRef, name: folderData.name, placeholderId: id }) - } else { - saveMechanism.updateFolder({ id, projectRef, name: folderData.name }) - } - sqlEditorState.needsSaving.delete(id) - } - }) - }) }