Files
supabase/apps/studio/state/sql-editor/sql-editor-lifecycle.test.ts
T
Charis d5653f1f92 refactor(studio): unify snippet save + persistence into SnippetStatus (3/9) (#47251)
## What

PR 3 of a stacked refactor of the SQL editor snippet state. 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.

## Status is attached at the data layer (never absent)

- `SnippetStatus` + `SnippetWithContent` now live in `data/content`. The
snippet queries attach `status: 'saved'` via a typed `withSavedStatus()`
helper, and `upsertContent` returns `SnippetWithContent` so move/rename
responses carry status too.
- A SQL-typed `getSqlSnippetById`/`useSqlSnippetByIdQuery` returns
`SnippetWithContent` (the generic `useContentIdQuery` stays for Reports,
which use it). `[id].tsx` loads content with **no casting**.
- `'new'` is attached on local creation (`createSqlSnippetSkeletonV2`).

## Behavior

Behavior-preserving for the existing auto-save flow (faithful mapping of
both old fields, including the replication-lag swallow). One incidental
fix: the read-only/saving indicator now also covers a brand-new
snippet's first save (previously only re-saves of persisted snippets had
distinct saving/failed states in some paths).

## Tests

New `sql-editor-lifecycle.test.ts` (29 tests) covering every predicate
and transition; existing rules tests updated. `pnpm --filter studio
typecheck` clean; 52 state/sql-editor unit tests pass.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **Refactor**
* Restructured SQL snippet persistence tracking, replacing boolean flags
with a comprehensive status system for clearer visibility into save
progress.
* Enhanced saving indicator UI to reflect accurate snippet save states.

* **Tests**
* Added test coverage for snippet persistence state transitions and
lifecycle scenarios.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-24 08:56:39 -04:00

119 lines
3.7 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
isSaveFailed,
isSaving,
statusOnSaveError,
statusOnSaveStart,
statusOnSaveSuccess,
wasNeverPersisted,
} 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('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)
})
})