mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 09:59:03 +08:00
Stacked on #48305. ## What PR 3 of the stacked SQL-editor query-source series (Database vs Logs). Stacked on the PR 2 branch `charislam/log-sql-content-shape`. Turns `SnippetWithContent` into a discriminated union on `type` and makes all snippet writes source-aware: - `data/content/sql-folders-query.ts`: `SnippetWithContent` is now `{ type: 'sql'; content?: SqlSnippets.Content } | { type: 'log_sql'; content?: LogSqlSnippets.Content } | { type: 'report'; content?: never }`. `report` is kept (the content endpoints' wire type carries it) but has no SQL content — its body is `Dashboards.Content`, loaded through the separate `Content` union. - `setSql` brands per type (`untrustedLogSql` vs `untrustedSql`). - `buildUpsertPayload` persists `snippet.type` (no longer hardcoded `'sql'`). - `createSqlSnippetSkeletonV2({ source })` emits the matching type + content shape with the `as any` cast removed. - New `components/interfaces/SQLEditor/querySource.ts`: `SqlSnippetSource` + `getSnippetSource`. - `seedSnippet` test helper gains a `source` arg. - New `remapWireSnippet` boundary helper in `content-remap.ts` concentrates the single wire->domain assertion, so `content-id-query` / `content-upsert-mutation` call sites are cast-free (no `as unknown as`). - Collateral: query result types aligned to the union; `updateSnippet` no longer accepts `type` (source is immutable); db-only editor read paths narrow away `log_sql`. ## Why Impossible-states-impossible typing: a snippet's brand follows its content type, so logs SQL and database SQL can never cross execution paths. No behavior change for existing database snippets. ## Testing - \`pnpm typecheck\` — clean - \`pnpm --filter studio run lint:ratchet\` — no new warnings - \`pnpm test:studio\` (data/content, SQLEditor, state/sql-editor) — passing, including new tests for \`getSnippetSource\`, source-aware \`setSql\`, type-aware \`buildUpsertPayload\`, and both skeleton shapes. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added source-aware creation for SQL editor snippets, including log-based SQL snippets. * Introduced backend source mapping so log snippets are treated as log_sql. * **Bug Fixes** * Improved SQL retrieval/prettification so log snippets no longer use the wrong fallback content. * Ensured log snippets are sanitized and preserve correct type, content, identifiers, and statuses during save/upsert flows. * **Tests** * Expanded unit and integration coverage for log snippet creation, source mapping, editing, prettification, and upsert payloads. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
317 lines
10 KiB
TypeScript
317 lines
10 KiB
TypeScript
import { untrustedSql } from '@supabase/pg-meta'
|
|
import { useMemo } from 'react'
|
|
import { toast } from 'sonner'
|
|
import { proxy, snapshot, useSnapshot } from 'valtio'
|
|
import { devtools, proxyMap } from 'valtio/utils'
|
|
|
|
import {
|
|
folderStatusOnSaveStart,
|
|
isNewFolder,
|
|
statusOnDiscard,
|
|
statusOnEdit,
|
|
} from './sql-editor-lifecycle'
|
|
import { sqlEditorSessionState } from './sql-editor-session-state'
|
|
import type { StateSnippet, StateSnippetFolder } from './types'
|
|
import type { SnippetWithContent } from '@/data/content/sql-folders-query'
|
|
import { Snippet, SnippetFolder } from '@/data/content/sql-folders-query'
|
|
import { untrustedLogSql } from '@/data/logs/safe-analytics-sql'
|
|
|
|
export const sqlEditorState = proxy({
|
|
// ========================================================================
|
|
// ## Data properties within the store
|
|
// ========================================================================
|
|
|
|
/**
|
|
* Currently limitations include supporting up to one level of folders from root, and only private snippets
|
|
*/
|
|
folders: {} as {
|
|
[folderId: string]: StateSnippetFolder
|
|
},
|
|
|
|
/**
|
|
* Private and shared snippets only, favorite snippets are derivatives of them by the `favorite` property
|
|
*/
|
|
snippets: {} as {
|
|
[snippetId: string]: StateSnippet
|
|
},
|
|
|
|
/**
|
|
* 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>([]),
|
|
|
|
get allFolderNames() {
|
|
return Object.values(sqlEditorState.folders).map((x) => x.folder.name)
|
|
},
|
|
|
|
// ========================================================================
|
|
// ## Methods to interact the store with
|
|
// ========================================================================
|
|
|
|
/**
|
|
* Load snippet into SQL Editor Valtio store
|
|
*/
|
|
addSnippet: ({ projectRef, snippet }: { projectRef: string; snippet: SnippetWithContent }) => {
|
|
if (sqlEditorState.snippets[snippet.id]) return
|
|
|
|
sqlEditorState.snippets[snippet.id] = { projectRef, splitSizes: [50, 50], snippet }
|
|
},
|
|
|
|
/**
|
|
*
|
|
* Clear local snippet content that is not persisted to the database. Deletes
|
|
* user edits that have not been saved.
|
|
*/
|
|
clearSnippetContent: (id: string) => {
|
|
const storeSnippet = sqlEditorState.snippets[id]
|
|
if (storeSnippet) {
|
|
storeSnippet.snippet.content = undefined
|
|
storeSnippet.snippet.status = statusOnDiscard(storeSnippet.snippet.status)
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Update snippet data (e.g name, visibility, chart) and queue for sync saving
|
|
*/
|
|
updateSnippet: ({
|
|
id,
|
|
snippet,
|
|
skipSave = false,
|
|
}: {
|
|
id: string
|
|
snippet: Omit<Partial<Snippet>, 'type'>
|
|
skipSave?: boolean
|
|
}) => {
|
|
if (sqlEditorState.snippets[id]) {
|
|
sqlEditorState.snippets[id].snippet = {
|
|
...sqlEditorState.snippets[id].snippet,
|
|
...snippet,
|
|
}
|
|
if (!skipSave) sqlEditorState.needsSaving.set(id, true)
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Load snippet content into the snippet within the Valtio store.
|
|
* Snippets fetched from the GET /content or /folders endpoints do not have the content loaded initially
|
|
* to reduce the response size from the API. Hence content for each snippet has to be loaded on demand
|
|
*/
|
|
setSnippet: (projectRef: string, snippet: SnippetWithContent) => {
|
|
let storedSnippet = sqlEditorState.snippets[snippet.id]
|
|
if (storedSnippet) {
|
|
if (!storedSnippet.snippet.content) {
|
|
storedSnippet.snippet.content = snippet.content
|
|
}
|
|
} else {
|
|
sqlEditorState.addSnippet({ projectRef: projectRef, snippet })
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Update the snippet content of a snippet and queue for sync saving
|
|
* Possibly can consolidate with `updateSnippet` to simplify
|
|
*/
|
|
setSql: ({
|
|
id,
|
|
sql,
|
|
shouldInvalidate = false,
|
|
}: {
|
|
id: string
|
|
sql: string
|
|
shouldInvalidate?: boolean
|
|
}) => {
|
|
let snippet = sqlEditorState.snippets[id]?.snippet
|
|
if (snippet?.content) {
|
|
if (snippet.type === 'log_sql') {
|
|
snippet.content.unchecked_sql = untrustedLogSql(sql)
|
|
} else {
|
|
snippet.content.unchecked_sql = untrustedSql(sql)
|
|
}
|
|
// Mark dirty immediately so `status` (not the transient needsSaving queue)
|
|
// is the durable source of truth for unsaved changes, even before the
|
|
// debounced save begins.
|
|
snippet.status = statusOnEdit(snippet.status)
|
|
sqlEditorState.needsSaving.set(id, shouldInvalidate)
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Update snippet in Valtio store after renaming
|
|
* Renaming a snippet follows an async saving and hence doesnt require queuing for sync saving here
|
|
* Refer to `RenameQueryModal.tsx` for more details
|
|
*/
|
|
renameSnippet: ({
|
|
id,
|
|
name,
|
|
description,
|
|
}: {
|
|
id: string
|
|
name: string
|
|
description?: string
|
|
}) => {
|
|
let snippet = sqlEditorState.snippets[id]?.snippet
|
|
if (snippet) {
|
|
snippet.name = name
|
|
snippet.description = description
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Remove snippet from the Valtio store, and optionally remove snippet from the sync saving queue
|
|
*/
|
|
removeSnippet: (id: string, skipSave: boolean = false) => {
|
|
const { [id]: snippet, ...otherSnippets } = sqlEditorState.snippets
|
|
sqlEditorState.snippets = otherSnippets
|
|
|
|
// Results/explain live in the session store; drop this snippet's entries.
|
|
sqlEditorSessionState.clearForSnippet(id)
|
|
|
|
if (!skipSave) sqlEditorState.needsSaving.delete(id)
|
|
},
|
|
|
|
/**
|
|
* Load folder into SQL Editor Valtio store
|
|
*/
|
|
addFolder: ({ projectRef, folder }: { projectRef: string; folder: SnippetFolder }) => {
|
|
if (sqlEditorState.folders[folder.id]) return
|
|
sqlEditorState.folders[folder.id] = { projectRef, status: 'idle', folder }
|
|
},
|
|
|
|
/**
|
|
* Adds a new folder placeholder for the UI to render. The placeholder gets a
|
|
* unique local id and `status: 'new_editing'` to mark it as not-yet-persisted
|
|
* (the status, not the id, is what tags it as new).
|
|
*/
|
|
addNewFolder: ({ projectRef }: { projectRef: string }) => {
|
|
const id = crypto.randomUUID()
|
|
sqlEditorState.folders[id] = {
|
|
projectRef,
|
|
status: 'new_editing',
|
|
folder: {
|
|
id,
|
|
name: '',
|
|
owner_id: -1,
|
|
project_id: -1,
|
|
parent_id: null,
|
|
},
|
|
}
|
|
},
|
|
|
|
editFolder: (id: string) => {
|
|
const storeFolder = sqlEditorState.folders[id]
|
|
if (storeFolder) {
|
|
storeFolder.status = isNewFolder(storeFolder.status) ? 'new_editing' : 'editing'
|
|
}
|
|
},
|
|
|
|
/**
|
|
* For renaming a folder, queue for sync saving if pass all validations
|
|
*/
|
|
saveFolder: ({ id, name }: { id: string; name: string }) => {
|
|
let storeFolder = sqlEditorState.folders[id]
|
|
const isNew = isNewFolder(storeFolder.status)
|
|
const hasChanges = storeFolder.folder.name !== name
|
|
const folderNameTaken = sqlEditorState.allFolderNames.includes(name)
|
|
|
|
if (isNew && folderNameTaken) {
|
|
sqlEditorState.removeFolder(id)
|
|
return toast.error('Unable to create new folder: This folder name already exists')
|
|
} else if (hasChanges && folderNameTaken) {
|
|
storeFolder.status = 'idle'
|
|
return toast.error('Unable to update folder: This folder name already exists')
|
|
}
|
|
|
|
const originalFolderName = storeFolder.folder.name.slice()
|
|
|
|
storeFolder.status = hasChanges ? folderStatusOnSaveStart(storeFolder.status) : 'idle'
|
|
storeFolder.folder.id = id
|
|
storeFolder.folder.name = name
|
|
|
|
if (hasChanges) {
|
|
// Remember this folder's own pre-rename name so a failed save can roll back.
|
|
storeFolder.previousName = originalFolderName
|
|
sqlEditorState.pendingFolderSaves.set(id, true)
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Remove folder from the Valtio store
|
|
* Deleting a folder follows an async saving and hence doesnt require queuing for sync saving here
|
|
* Refer to `SQLEditorNav` for more details (ConfirmationModal for deleting a folder)
|
|
*/
|
|
removeFolder: (id: string) => {
|
|
const { [id]: folder, ...otherFolders } = sqlEditorState.folders
|
|
sqlEditorState.folders = otherFolders
|
|
},
|
|
|
|
addNeedsSaving: (id: string) => sqlEditorState.needsSaving.set(id, true),
|
|
|
|
addFavorite: (id: string) => {
|
|
const storeSnippet = sqlEditorState.snippets[id]
|
|
if (storeSnippet) {
|
|
storeSnippet.snippet.favorite = true
|
|
sqlEditorState.needsSaving.set(id, true)
|
|
}
|
|
},
|
|
|
|
removeFavorite: (id: string) => {
|
|
const storeSnippet = sqlEditorState.snippets[id]
|
|
if (storeSnippet) {
|
|
storeSnippet.snippet.favorite = false
|
|
sqlEditorState.needsSaving.set(id, true)
|
|
}
|
|
},
|
|
})
|
|
|
|
// ========================================================================
|
|
// ## Expose entry points into this Valtio store
|
|
// ========================================================================
|
|
|
|
export const getSqlEditorV2StateSnapshot = () => snapshot(sqlEditorState)
|
|
|
|
export const useSqlEditorV2StateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) =>
|
|
useSnapshot(sqlEditorState, options)
|
|
|
|
export const useSnippetFolders = (projectRef: string) => {
|
|
const snapshot = useSqlEditorV2StateSnapshot()
|
|
|
|
return useMemo(
|
|
() =>
|
|
Object.values(snapshot.folders)
|
|
.filter((x) => x.projectRef === projectRef)
|
|
.map((x) => x.folder)
|
|
// folders don't have created_at or inserted_at, so we always sort by name
|
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
[projectRef, snapshot.folders]
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Get ALL snippets for a project
|
|
*/
|
|
export const useSnippets = (projectRef: string) => {
|
|
const snapshot = useSqlEditorV2StateSnapshot()
|
|
|
|
return useMemo(
|
|
() =>
|
|
Object.values(snapshot.snippets)
|
|
.filter((storeSnippet) => storeSnippet.projectRef === projectRef)
|
|
.map((storeSnippet) => storeSnippet.snippet),
|
|
[projectRef, snapshot.snippets]
|
|
)
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
devtools(sqlEditorState, {
|
|
name: 'sqlEditorStateV2',
|
|
// [Joshen] So that jest unit tests can ignore this
|
|
enabled: process.env.NEXT_PUBLIC_ENVIRONMENT !== undefined,
|
|
})
|
|
}
|