mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
refactor(studio): move SQL editor save trigger into a scheduler + provider (5/9) (#47316)
## 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).
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
f2f346dfde
commit
5cb81123ae
10 files changed
+433
-66
No files matched your search
@@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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 = ({
|
||||
<TabsStateContextProvider key={`tabs-state-${projectRef}`}>
|
||||
<DatabaseSelectorStateContextProvider key={`database-selector-state-${projectRef}`}>
|
||||
<RoleImpersonationStateContextProvider key={`role-impersonation-state-${projectRef}`}>
|
||||
{children}
|
||||
<SqlEditorSaveCoordinatorProvider>{children}</SqlEditorSaveCoordinatorProvider>
|
||||
</RoleImpersonationStateContextProvider>
|
||||
</DatabaseSelectorStateContextProvider>
|
||||
</TabsStateContextProvider>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
@@ -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<SaveScheduler, 'requestSave'>
|
||||
|
||||
const SqlEditorSaveCoordinatorContext = createContext<SaveCoordinator | null>(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 (
|
||||
<SqlEditorSaveCoordinatorContext.Provider value={scheduler}>
|
||||
{children}
|
||||
</SqlEditorSaveCoordinatorContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSqlEditorSaveCoordinator() {
|
||||
const coordinator = useContext(SqlEditorSaveCoordinatorContext)
|
||||
if (coordinator === null) {
|
||||
throw new Error(
|
||||
'useSqlEditorSaveCoordinator must be used within a SqlEditorSaveCoordinatorProvider'
|
||||
)
|
||||
}
|
||||
return coordinator
|
||||
}
|
||||
@@ -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<void>((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
function makeStateSnippet(overrides: Partial<StateSnippet['snippet']> = {}): 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<string, boolean>(),
|
||||
pendingFolderSaves: proxyMap<string, boolean>(),
|
||||
snippets: {} as Record<string, StateSnippet>,
|
||||
folders: {} as Record<string, StateSnippetFolder>,
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, boolean>
|
||||
/** Folder ids queued for save (create or rename). */
|
||||
pendingFolderSaves: Map<string, boolean>
|
||||
snippets: { [id: string]: StateSnippet | undefined }
|
||||
folders: { [id: string]: StateSnippetFolder | undefined }
|
||||
}
|
||||
|
||||
export interface SaveSchedulerDeps {
|
||||
state: SaveSchedulerStore
|
||||
saveMechanism: Pick<SaveMechanism, 'saveSnippet' | 'createFolder' | 'updateFolder'>
|
||||
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<typeof createSaveScheduler>
|
||||
@@ -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<string, boolean>([]),
|
||||
/**
|
||||
* Folders queued for saving (create or rename). Kept separate from
|
||||
* `needsSaving` so snippet and folder saves are scheduled independently.
|
||||
*/
|
||||
pendingFolderSaves: proxyMap<string, boolean>([]),
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user