import { debounce, memoize } from 'lodash' import { statusOnSaveError, statusOnSaveStart, statusOnSaveSuccess } from './sql-editor-lifecycle' import { buildUpsertPayload, isLoadedSnippet } from './sql-editor-rules' import type { StateSnippet, StateSnippetFolder } from './types' import type { UpsertContentPayload } from '@/data/content/content-upsert-mutation' import type { SnippetFolder } from '@/data/content/sql-folders-query' import { getErrorMessage } from '@/lib/get-error-message' import type { Notifier } from '@/lib/notifier' const GENERIC_ERROR_MESSAGE = 'an unexpected error occurred' /** * The slice of the SQL editor store the save mechanism reads from and writes to. * Declared structurally (rather than depending on the concrete store) so the * mechanism can be exercised in isolation with a plain fake. */ export interface SaveMechanismStore { snippets: { [id: string]: StateSnippet | undefined } folders: { [id: string]: StateSnippetFolder | undefined } removeFolder: (id: string) => void } export interface SaveMechanismDeps { state: SaveMechanismStore upsertContent: (vars: { projectRef: string; payload: UpsertContentPayload }) => Promise createSQLSnippetFolder: (vars: { projectRef: string; name: string }) => Promise updateSQLSnippetFolder: (vars: { projectRef: string id: string name: string }) => Promise /** Invalidate the snippet/folder/count lists for a project. */ invalidate: (projectRef: string) => Promise /** Surface success/error toasts. */ notify: Notifier /** Build the upsert payload. Injectable for testing; defaults to buildUpsertPayload. */ buildPayload?: typeof buildUpsertPayload /** Snippet save debounce in ms. Defaults to 1000. */ debounceMs?: number } export interface SaveSnippetArgs { id: string projectRef: string shouldInvalidate: boolean } export interface CreateFolderArgs { projectRef: string name: string /** Id of the local placeholder folder to swap for the persisted one. */ placeholderId: string } export interface UpdateFolderArgs { id: string projectRef: string name: string } /** * The save *mechanism*: it knows how to persist a snippet or folder and how to * reflect that in the store (status transitions, list invalidation, folder * placeholder swap / rollback). It does NOT decide *when* to save — that policy * lives in the store's subscribe today, and moves to a scheduler in a later PR. * * Dependencies (data-layer calls, query invalidation, notifications, the store, * the debounce window) are injected, and the per-snippet debounce cache lives in * this factory closure so each instance — and each test — starts clean. */ export function createSaveMechanism(deps: SaveMechanismDeps) { const { state, upsertContent, createSQLSnippetFolder, updateSQLSnippetFolder, invalidate, notify, buildPayload = buildUpsertPayload, debounceMs = 1000, } = deps type PersistResult = { status: 'skipped' | 'success' } | { status: 'error'; error: unknown } /** * Upsert a snippet's full current state and reflect the outcome in its * `status`. Shared by the debounced content save and the immediate favorite * save — the endpoint has no partial update, so both send the whole snippet. */ async function persistSnippet(id: string, projectRef: string): Promise { const snippet = state.snippets[id]?.snippet // Only persist a snippet whose content has been loaded — otherwise we would // PUT an empty content body and clobber the stored SQL. if (snippet === undefined || !isLoadedSnippet(snippet)) return { status: 'skipped' } const payload = buildPayload(snippet, id) try { snippet.status = statusOnSaveStart(snippet.status) await upsertContent({ projectRef, payload }) snippet.status = statusOnSaveSuccess() return { status: 'success' } } catch (error) { snippet.status = statusOnSaveError(snippet.status) return { status: 'error', error } } } async function saveSnippet({ id, projectRef, shouldInvalidate }: SaveSnippetArgs) { const result = await persistSnippet(id, projectRef) if (result.status === 'success' && shouldInvalidate) await invalidate(projectRef) } const memoizedSaveSnippet = memoize((_id: string) => debounce(saveSnippet, debounceMs)) /** Debounced per snippet id; rapid edits to one snippet coalesce to one save. */ function scheduleSaveSnippet(args: SaveSnippetArgs) { memoizedSaveSnippet(args.id)(args) } /** * Persist a snippet's favorite flag immediately, bypassing the debounce and * save-mode policy that content edits go through. Any pending debounced * content save for this id is cancelled since it would otherwise duplicate * this request. `previousFavorite` is the value the caller applied the * optimistic change over, so a failed save can roll back to exactly that — * rather than inverting whatever the field happens to hold once the request * settles, which could be wrong if the flag was toggled again in the meantime. * Always invalidates on success — the Favorites nav section is backed by its * own `favorite: true` query, not a live filter over this store, so it won't * otherwise notice the flag changed. */ async function saveFavorite({ id, projectRef, previousFavorite, }: { id: string projectRef: string previousFavorite: boolean }) { memoizedSaveSnippet(id).cancel() const result = await persistSnippet(id, projectRef) if (result.status === 'success') { await invalidate(projectRef) } else if (result.status === 'error') { notify.error( `Failed to update favorite: ${getErrorMessage(result.error) ?? GENERIC_ERROR_MESSAGE}` ) const snippet = state.snippets[id]?.snippet if (snippet) snippet.favorite = previousFavorite } } async function createFolder({ projectRef, name, placeholderId }: CreateFolderArgs) { try { const folder = await createSQLSnippetFolder({ projectRef, name }) notify.success('Successfully created folder') // Swap the local placeholder for the persisted folder. state.removeFolder(placeholderId) state.folders[folder.id] = { projectRef, status: 'idle', folder } } catch (error: unknown) { notify.error(`Failed to save folder: ${getErrorMessage(error) ?? GENERIC_ERROR_MESSAGE}`) // Roll back the placeholder — there is no persisted folder to keep. state.removeFolder(placeholderId) } } async function updateFolder({ id, projectRef, name }: UpdateFolderArgs) { const storeFolder = state.folders[id] if (!storeFolder) return try { await updateSQLSnippetFolder({ projectRef, id, name }) notify.success('Successfully updated folder') } catch (error: unknown) { notify.error(`Failed to save folder: ${getErrorMessage(error) ?? GENERIC_ERROR_MESSAGE}`) // Roll back the optimistic rename to this folder's own previous name. if (storeFolder.previousName !== undefined) { storeFolder.folder.name = storeFolder.previousName } } finally { storeFolder.status = 'idle' storeFolder.previousName = undefined } } return { /** Schedule a debounced save of the snippet with the given id. */ saveSnippet: scheduleSaveSnippet, /** Persist a snippet's favorite flag immediately. */ saveFavorite, /** Persist a new folder, swapping out its local placeholder. */ createFolder, /** Persist a folder rename. */ updateFolder, } } export type SaveMechanism = ReturnType