Files
supabase/apps/studio/state/sql-editor/sql-editor-lifecycle.test.ts
Charis 5cb81123ae 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 -->
2026-06-25 16:24:04 -04:00

189 lines
5.9 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
folderStatusOnSaveStart,
hasUnsavedChanges,
isFolderEditing,
isFolderSaving,
isNewFolder,
isSaveFailed,
isSaving,
statusOnSaveError,
statusOnSaveStart,
statusOnSaveSuccess,
wasNeverPersisted,
type FolderStatus,
} 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('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')
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)
})
})
const NEW_FOLDER: Array<FolderStatus> = ['new_editing', 'new_saving']
const PERSISTED_FOLDER: Array<FolderStatus> = ['editing', 'saving', 'idle']
describe('isNewFolder', () => {
it.each(NEW_FOLDER)('is true for not-yet-persisted folder status %s', (status) => {
expect(isNewFolder(status)).toBe(true)
})
it.each(PERSISTED_FOLDER)('is false for persisted folder status %s', (status) => {
expect(isNewFolder(status)).toBe(false)
})
it('treats an absent status as not-new', () => {
expect(isNewFolder(undefined)).toBe(false)
})
})
describe('isFolderEditing', () => {
it('is true only while the name is being edited inline (new or persisted)', () => {
expect(isFolderEditing('new_editing')).toBe(true)
expect(isFolderEditing('editing')).toBe(true)
})
it.each(['new_saving', 'saving', 'idle', undefined] as const)('is false for %s', (status) => {
expect(isFolderEditing(status)).toBe(false)
})
})
describe('isFolderSaving', () => {
it('is true only while a create/rename is in flight (new or persisted)', () => {
expect(isFolderSaving('new_saving')).toBe(true)
expect(isFolderSaving('saving')).toBe(true)
})
it.each(['new_editing', 'editing', 'idle', undefined] as const)('is false for %s', (status) => {
expect(isFolderSaving(status)).toBe(false)
})
})
describe('folderStatusOnSaveStart', () => {
it('keeps a new folder in the new family', () => {
expect(folderStatusOnSaveStart('new_editing')).toBe('new_saving')
})
it('moves a persisted folder to saving', () => {
expect(folderStatusOnSaveStart('editing')).toBe('saving')
expect(folderStatusOnSaveStart('idle')).toBe('saving')
})
})