diff --git a/apps/studio/components/interfaces/BranchManagement/SwitchToPreviewModal.test.tsx b/apps/studio/components/interfaces/BranchManagement/SwitchToPreviewModal.test.tsx new file mode 100644 index 0000000000000..c935c186525c4 --- /dev/null +++ b/apps/studio/components/interfaces/BranchManagement/SwitchToPreviewModal.test.tsx @@ -0,0 +1,103 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { mockAnimationsApi } from 'jsdom-testing-mocks' +import { HttpResponse } from 'msw' +import { toast } from 'sonner' +import { describe, expect, test, vi } from 'vitest' + +import { SwitchToPreviewModal } from './SwitchToPreviewModal' +import type { components } from '@/data/api' +import type { Branch } from '@/data/branches/branches-query' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw' + +mockAnimationsApi() + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +type BranchUpdateResponse = components['schemas']['BranchUpdateResponse'] + +const PARENT_PROJECT_REF = 'parent-project-ref' +const BRANCH_PROJECT_REF = 'branch-project-ref' + +const BRANCH: Branch = { + created_at: '2026-01-01T00:00:00.000Z', + id: '00000000-0000-0000-0000-000000000001', + is_default: false, + name: 'docs-local-staging', + parent_project_ref: PARENT_PROJECT_REF, + persistent: true, + project_ref: BRANCH_PROJECT_REF, + status: 'MIGRATIONS_PASSED', + updated_at: '2026-01-01T00:00:00.000Z', + with_data: false, +} + +const mockBranchUpdate = () => { + const requests: Array<{ branchRef: string | undefined; body: unknown }> = [] + + addAPIMock({ + method: 'patch', + path: '/v1/branches/:branch_id_or_ref', + response: async ({ request, params }) => { + requests.push({ + branchRef: params.branch_id_or_ref as string | undefined, + body: await request.json(), + }) + return HttpResponse.json({ + message: 'ok', + workflow_run_id: 'workflow-run-1', + }) + }, + }) + + return requests +} + +const renderModal = (overrides: { branch?: Branch; onClose?: () => void } = {}) => { + const onClose = overrides.onClose ?? vi.fn() + customRender() + return { onClose } +} + +describe('SwitchToPreviewModal', () => { + test('switches the branch to preview using the refs on the branch', async () => { + const requests = mockBranchUpdate() + + const { onClose } = renderModal({ branch: BRANCH }) + + await userEvent.click(await screen.findByRole('button', { name: 'Switch to preview' })) + + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + expect(requests).toEqual([{ branchRef: BRANCH_PROJECT_REF, body: { persistent: false } }]) + expect(toast.success).toHaveBeenCalledWith('Successfully updated branch') + }) + + test('surfaces the error and keeps the modal open when the update fails', async () => { + addAPIMock({ + method: 'patch', + path: '/v1/branches/:branch_id_or_ref', + response: () => + HttpResponse.json({ message: 'Something exploded' }, { status: 500 }), + }) + + const { onClose } = renderModal({ branch: BRANCH }) + + const confirm = await screen.findByRole('button', { name: 'Switch to preview' }) + await userEvent.click(confirm) + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith('Failed to update branch: Something exploded') + ) + expect(onClose).not.toHaveBeenCalled() + expect(confirm).toBeEnabled() + }) + + test('disables the confirm button while the branch is unavailable', async () => { + renderModal() + + expect(await screen.findByRole('button', { name: 'Switch to preview' })).toBeDisabled() + }) +}) diff --git a/apps/studio/components/interfaces/BranchManagement/SwitchToPreviewModal.tsx b/apps/studio/components/interfaces/BranchManagement/SwitchToPreviewModal.tsx index d86c42a8389a3..d464477798287 100644 --- a/apps/studio/components/interfaces/BranchManagement/SwitchToPreviewModal.tsx +++ b/apps/studio/components/interfaces/BranchManagement/SwitchToPreviewModal.tsx @@ -3,7 +3,6 @@ import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' import { useBranchUpdateMutation } from '@/data/branches/branch-update-mutation' import { type Branch } from '@/data/branches/branches-query' -import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' interface SwitchToPreviewModalProps { open: boolean @@ -12,11 +11,6 @@ interface SwitchToPreviewModalProps { } export const SwitchToPreviewModal = ({ open, branch, onClose }: SwitchToPreviewModalProps) => { - const { data: project } = useSelectedProjectQuery() - - const branchRef = project?.ref - const projectRef = project?.parent_project_ref - const { mutate: updateBranch, isPending: isUpdatingBranch } = useBranchUpdateMutation({ onSuccess() { toast.success('Successfully updated branch') @@ -24,11 +18,13 @@ export const SwitchToPreviewModal = ({ open, branch, onClose }: SwitchToPreviewM }, }) - const onTogglePersistent = () => { - if (branchRef === undefined || projectRef === undefined || branch === undefined) { - return console.error('Branch metadata is required') - } - updateBranch({ branchRef, projectRef, persistent: !branch.persistent }) + const onSwitchToPreview = () => { + if (branch === undefined) return + updateBranch({ + branchRef: branch.project_ref, + projectRef: branch.parent_project_ref, + persistent: false, + }) } return ( @@ -38,8 +34,9 @@ export const SwitchToPreviewModal = ({ open, branch, onClose }: SwitchToPreviewM confirmLabel="Switch to preview" title="Switch branch to preview before deleting" loading={isUpdatingBranch} + disabled={branch === undefined} onCancel={() => onClose()} - onConfirm={onTogglePersistent} + onConfirm={onSwitchToPreview} >

You must switch the branch "{branch?.name}" to preview before deleting it. diff --git a/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx index 696408859c214..d772a94a77e27 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx @@ -12,15 +12,18 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable' import { acceptUntrustedSql } from '@supabase/pg-meta' -import { useParams } from 'common' +import { LOCAL_STORAGE_KEYS, useParams } from 'common' import { + Check, FileText, + Keyboard, Loader2, MoreVertical, Notebook, NotebookText, Play, Save, + SearchX, SquareCode, Trash, } from 'lucide-react' @@ -33,6 +36,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' @@ -59,6 +63,7 @@ import { } from '@/data/content/notebooks/notebook-schema' import { useUpsertNotebookMutation } from '@/data/content/notebooks/notebook-upsert-mutation' import { acceptUntrustedLogsSql } from '@/data/logs/safe-analytics-sql' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' import { createTabId, useTabsStateSnapshot } from '@/state/tabs' @@ -68,6 +73,11 @@ export const ExplorerNotebookTab = () => { const tabs = useTabsStateSnapshot() const snap = useNotebooksStateSnapshot() + const [isIntellisenseEnabled, setIsIntellisenseEnabled] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE, + true + ) + const currentNotebook = useCurrentNotebook() const { name, content } = currentNotebook?.notebook ?? {} const { isNotFound } = useLoadNotebook({ id, projectRef: ref }) @@ -181,9 +191,9 @@ export const ExplorerNotebookTab = () => { if (isNotFound) { return ( -

+
} + icon={} title="Notebook not found" description="This notebook may have been deleted or does not exist." contentClassName="[&>h3]:text-sm [&>p]:text-xs" @@ -231,7 +241,18 @@ export const ExplorerNotebookTab = () => { } /> - + + setIsIntellisenseEnabled(!isIntellisenseEnabled)} + > +
+ + Intellisense enabled +
+ {isIntellisenseEnabled && } +
+ setIsDeleteModalOpen(true)}> Delete notebook diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx index 8ddfc51f5d8de..15fd6e59fc622 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx @@ -1,3 +1,4 @@ +import { useMonaco } from '@monaco-editor/react' import { acceptUntrustedSql, untrustedSql, type UntrustedSqlFragment } from '@supabase/pg-meta' import { useFlag } from 'common' import { CodeSquare, Eye, EyeOff, Play } from 'lucide-react' @@ -34,6 +35,7 @@ import { QueryResultRenderer } from './QueryResultRenderer' import { QuerySourceMenu } from './QuerySourceMenu' import { useQueryEditorAi } from './useQueryEditorAi' import { LegacyLogsRewriteBanner } from '@/components/interfaces/Settings/Logs/LegacyLogsRewriteBanner' +import { useAddDefinitions } from '@/components/interfaces/SQLEditor/useAddDefinitions' import { ResizableAIWidget } from '@/components/ui/AIEditor/ResizableAIWidget' import { getEditorSelectionParts, type EditorSelection } from '@/components/ui/AIEditor/utils' import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' @@ -173,6 +175,9 @@ export const QueryEditor = forwardRef(funct const dialect = query._tag === 'logs' ? 'clickhouse' : 'postgres' const { requestCompletion, isCompletionLoading } = useQueryEditorAi({ dialect }) + const monaco = useMonaco() + useAddDefinitions('', monaco, { enabled: dialect === 'postgres' }) + const { data: databases, isPending: isLoadingDatabases } = useReadReplicasQuery( { projectRef: project?.ref }, { @@ -339,15 +344,11 @@ export const QueryEditor = forwardRef(funct onClick={() => setShowQuery((value) => !value)} /> } tooltip="Run query" disabled={ - isLoadingProject || - isExecuting || - pendingProposal !== null || - isRunDisabled || - sql.trim().length === 0 + isBusy || pendingProposal !== null || isRunDisabled || sql.trim().length === 0 } onClick={() => handleRunQuery()} > @@ -381,8 +382,13 @@ export const QueryEditor = forwardRef(funct placeholder={!promptState?.isOpen ? generatePlaceholder(os) : ''} placeholderClassName="top-[13px]" className={variant === 'embedded' ? 'h-44' : undefined} - actions={{ runQuery: { enabled: !isRunDisabled, callback: handleRunQuery } }} - options={{ minimap: { enabled: false }, padding: { top: 8 } }} + actions={{ + runQuery: { enabled: !isRunDisabled, callback: handleRunQuery }, + }} + options={{ + minimap: { enabled: false }, + padding: { top: 8 }, + }} onInputChange={(value) => onSqlChange(value ?? '')} onMount={(editor, monaco) => { editor.onDidBlurEditorWidget(() => onSqlCommitRef.current?.(sqlRef.current)) diff --git a/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx b/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx index 71425a3fb5eb2..389e07dcaf85e 100644 --- a/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx +++ b/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx @@ -1,5 +1,6 @@ import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common' import { HttpResponse } from 'msw' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -87,16 +88,25 @@ beforeEach(() => { seedNotebook([databaseCell, logCell, markdownCell]) }) -afterEach(() => notebooksState.needsSaving.clear()) +afterEach(() => { + notebooksState.needsSaving.clear() + safeLocalStorage.removeItem(LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE) +}) describe('ExplorerNotebookTab', () => { it('runs every database and log cell, and skips markdown cells, on "Run notebook"', async () => { + // `useAddDefinitions` fires its own background keywords/functions/schemas/table-columns + // fetches against this same generic pg-meta query endpoint (differentiated by the `key` + // search param) as soon as a database cell's editor mounts — those are expected and + // unrelated to the actual cell run. + const INTELLISENSE_KEYS = ['keywords', 'database-functions', 'schemas', 'table-columns'] const dbRequests: Request[] = [] addAPIMock({ method: 'post', path: '/platform/pg-meta/:ref/query', response: ({ request }) => { - dbRequests.push(request) + const key = new URL(request.url).searchParams.get('key') + if (!key || !INTELLISENSE_KEYS.includes(key)) dbRequests.push(request) return HttpResponse.json([{ result: 1 }]) }, }) @@ -130,4 +140,22 @@ describe('ExplorerNotebookTab', () => { const runNotebookButton = await screen.findByRole('button', { name: 'Run notebook' }) expect(runNotebookButton).toBeDisabled() }) + + it('toggles and persists the Intellisense enabled preference from "More options"', async () => { + const readPersistedValue = () => { + const item = safeLocalStorage.getItem(LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE) + return item === null ? true : (JSON.parse(item) as boolean) + } + const initialValue = readPersistedValue() + + renderNotebookTab() + + const moreOptionsButton = await screen.findByRole('button', { name: 'More options' }) + await userEvent.click(moreOptionsButton) + + const intellisenseItem = await screen.findByRole('menuitem', { name: 'Intellisense enabled' }) + await userEvent.click(intellisenseItem) + + await waitFor(() => expect(readPersistedValue()).toBe(!initialValue)) + }) }) diff --git a/apps/studio/components/interfaces/Explorer/__tests__/QueryTab.test.tsx b/apps/studio/components/interfaces/Explorer/__tests__/QueryTab.test.tsx index 9df7654ff50eb..9157409706477 100644 --- a/apps/studio/components/interfaces/Explorer/__tests__/QueryTab.test.tsx +++ b/apps/studio/components/interfaces/Explorer/__tests__/QueryTab.test.tsx @@ -130,12 +130,18 @@ describe('QueryTab execution', () => { return HttpResponse.json([]) }, }) + // `useAddDefinitions` fires its own background keywords/functions/schemas/table-columns + // fetches against this same generic pg-meta query endpoint (differentiated by the `key` + // search param) as soon as the editor mounts — those are expected and unrelated to the + // actual run. Only a request with no recognized intellisense `key` counts as an execution. + const INTELLISENSE_KEYS = ['keywords', 'database-functions', 'schemas', 'table-columns'] const requests: Request[] = [] addAPIMock({ method: 'post', path: '/platform/pg-meta/:ref/query', response: ({ request }) => { - requests.push(request) + const key = new URL(request.url).searchParams.get('key') + if (!key || !INTELLISENSE_KEYS.includes(key)) requests.push(request) return HttpResponse.json([]) }, }) diff --git a/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.test.ts b/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.test.ts new file mode 100644 index 0000000000000..d398f6d010b12 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.test.ts @@ -0,0 +1,151 @@ +import { QueryClient } from '@tanstack/react-query' +import { waitFor } from '@testing-library/react' +import { HttpResponse } from 'msw' +import { describe, expect, it, vi } from 'vitest' + +import { acquireSharedRegistration, useAddDefinitions } from './useAddDefinitions' +import { customRenderHook } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' +import { setupSqlEditorMocks } from '@/tests/lib/sql-editor-test-utils' + +vi.mock('@/components/ui/CodeEditor/Providers/PgSQLCompletionProvider', () => ({ + default: vi.fn((_monaco: unknown, pgInfoRef: unknown) => ({ __pgInfoRef: pgInfoRef })), +})) +vi.mock('@/components/ui/CodeEditor/Providers/PgSQLSignatureHelpProvider', () => ({ + default: vi.fn((_monaco: unknown, pgInfoRef: unknown) => ({ __pgInfoRef: pgInfoRef })), +})) + +describe('acquireSharedRegistration', () => { + it('only registers once for multiple concurrent callers sharing a key', () => { + const register = vi.fn(() => ({ dispose: vi.fn() })) + + acquireSharedRegistration('test-key-1', register) + acquireSharedRegistration('test-key-1', register) + acquireSharedRegistration('test-key-1', register) + + expect(register).toHaveBeenCalledTimes(1) + }) + + it('registers independently per key', () => { + const register = vi.fn(() => ({ dispose: vi.fn() })) + + acquireSharedRegistration('test-key-2a', register) + acquireSharedRegistration('test-key-2b', register) + + expect(register).toHaveBeenCalledTimes(2) + }) + + it('does not dispose while other callers are still holding the registration', () => { + const dispose = vi.fn() + const register = vi.fn(() => ({ dispose })) + + const releaseA = acquireSharedRegistration('test-key-3', register) + acquireSharedRegistration('test-key-3', register) + + releaseA() + + expect(dispose).not.toHaveBeenCalled() + }) + + it('disposes once the last caller releases', () => { + const dispose = vi.fn() + const register = vi.fn(() => ({ dispose })) + + const releaseA = acquireSharedRegistration('test-key-4', register) + const releaseB = acquireSharedRegistration('test-key-4', register) + + releaseA() + releaseB() + + expect(dispose).toHaveBeenCalledTimes(1) + }) + + it('registers again after a full release cycle', () => { + const register = vi.fn(() => ({ dispose: vi.fn() })) + + const release = acquireSharedRegistration('test-key-5', register) + release() + acquireSharedRegistration('test-key-5', register) + + expect(register).toHaveBeenCalledTimes(2) + }) + + it('is safe to release more times than acquired', () => { + const dispose = vi.fn() + const register = vi.fn(() => ({ dispose })) + + const release = acquireSharedRegistration('test-key-6', register) + release() + + expect(() => release()).not.toThrow() + expect(dispose).toHaveBeenCalledTimes(1) + }) +}) + +describe('useAddDefinitions', () => { + const createMonaco = () => + ({ + languages: { + registerCompletionItemProvider: vi.fn(() => ({ dispose: vi.fn() })), + registerSignatureHelpProvider: vi.fn(() => ({ dispose: vi.fn() })), + registerDocumentFormattingEditProvider: vi.fn(() => ({ dispose: vi.fn() })), + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + + it('keeps the registered provider reading fresh data after the registering editor unmounts, as long as a sibling editor is still active', async () => { + const getPgsqlCompletionProvider = ( + await import('@/components/ui/CodeEditor/Providers/PgSQLCompletionProvider') + ).default as unknown as ReturnType + + let keywordWords = ['select'] + setupSqlEditorMocks() + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: ({ request }) => { + const key = new URL(request.url).searchParams.get('key') + if (key === 'keywords') return HttpResponse.json(keywordWords.map((word) => ({ word }))) + return HttpResponse.json([]) + }, + }) + + const monaco = createMonaco() + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const renderOptions = { queryClient } + + // Two sibling notebook cells' editors, both mounted at once, sharing the same cache. + const editor1 = customRenderHook( + () => useAddDefinitions('', monaco, { enabled: true }), + renderOptions + ) + const editor2 = customRenderHook( + () => useAddDefinitions('', monaco, { enabled: true }), + renderOptions + ) + + await waitFor(() => + expect(monaco.languages.registerCompletionItemProvider).toHaveBeenCalledTimes(1) + ) + + // The first (and only, thanks to ref-counted registration) call registered the provider — + // capture the `pgInfoRef` it reads from. + const pgInfoRef = getPgsqlCompletionProvider.mock.calls[0][1] as { + current: { keywords: string[] } + } + await waitFor(() => expect(pgInfoRef.current.keywords).toEqual(['select'])) + + // Editor 1 — the one whose render happened to trigger the registration — closes. Editor 2 + // is still open, so the provider must stay registered and stay current. + editor1.unmount() + expect(monaco.languages.registerCompletionItemProvider).toHaveBeenCalledTimes(1) + + keywordWords = ['select', 'insert'] + queryClient.invalidateQueries({ queryKey: ['projects', 'default', 'keywords'] }) + editor2.rerender() + + await waitFor(() => expect(pgInfoRef.current.keywords).toEqual(['select', 'insert'])) + + editor2.unmount() + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.ts b/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.ts index 490c9b3a86a9f..8e903636956ed 100644 --- a/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.ts +++ b/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.ts @@ -1,11 +1,13 @@ import { Monaco } from '@monaco-editor/react' +import { useQueryClient } from '@tanstack/react-query' import { LOCAL_STORAGE_KEYS } from 'common' import type { IDisposable } from 'monaco-editor' -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' import getPgsqlCompletionProvider from '@/components/ui/CodeEditor/Providers/PgSQLCompletionProvider' import getPgsqlSignatureHelpProvider from '@/components/ui/CodeEditor/Providers/PgSQLSignatureHelpProvider' import { useDatabaseFunctionsQuery } from '@/data/database-functions/database-functions-query' +import { databaseKeys } from '@/data/database/keys' import { useKeywordsQuery } from '@/data/database/keywords-query' import { useSchemasQuery } from '@/data/database/schemas-query' import { useTableColumnsQuery } from '@/data/database/table-columns-query' @@ -15,6 +17,41 @@ import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { formatSql } from '@/lib/formatSql' import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' +/** + * Prevents double registration of Monaco providers (e.g registerCompletionItemProvider) + */ +const sharedRegistrations = new Map() + +/** + * Shared across every `useAddDefinitions` instance so the registered pgsql completion/signature + * providers always read the freshest data from *any* currently active editor, not just whichever + * instance's `register()` factory happened to run first. Without this, the provider stays bound + * to a single per-instance ref: if that instance unmounts while a sibling editor is still active, + * `acquireSharedRegistration`'s ref-count doesn't reach zero (so nothing gets disposed), but the + * provider is left reading a ref that will never be written to again. + */ +const sharedPgInfoRef: { current: any } = { current: null } + +export function acquireSharedRegistration(key: string, register: () => IDisposable) { + const existing = sharedRegistrations.get(key) + if (existing) { + existing.count += 1 + } else { + sharedRegistrations.set(key, { count: 1, disposable: register() }) + } + + return () => { + const entry = sharedRegistrations.get(key) + if (!entry) return + + entry.count -= 1 + if (entry.count <= 0) { + entry.disposable?.dispose() + sharedRegistrations.delete(key) + } + } +} + export const useAddDefinitions = ( id: string, monaco: Monaco | null, @@ -22,6 +59,7 @@ export const useAddDefinitions = ( ) => { const { data: project } = useSelectedProjectQuery() const snapV2 = useSqlEditorV2StateSnapshot() + const queryClient = useQueryClient() const [intellisenseEnabled] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE, @@ -57,8 +95,6 @@ export const useAddDefinitions = ( { enabled: enabled && intellisenseEnabled } ) - const pgInfoRef = useRef(null) - const filteredSchemas = useSchemasFilteredForHighAvailability(schemas) const isPgInfoReady = @@ -69,20 +105,47 @@ export const useAddDefinitions = ( isKeywordsSuccess && isFunctionsSuccess - if (isPgInfoReady) { - if (pgInfoRef.current === null) { - pgInfoRef.current = {} + // Keeps `sharedPgInfoRef` current for the registered pgsql completion/signature-help + // providers. Runs in an effect — not render — so only committed tree state writes the + // shared, module-level ref; mutating it directly during render risked a discarded or + // interrupted render pass leaking a write that no committed render ever produced. + useEffect(() => { + if (isPgInfoReady) { + if (sharedPgInfoRef.current === null) { + sharedPgInfoRef.current = {} + } + sharedPgInfoRef.current.tableColumns = tableColumns + sharedPgInfoRef.current.schemas = filteredSchemas + sharedPgInfoRef.current.keywords = keywords + sharedPgInfoRef.current.functions = functions + } else if (!intellisenseEnabled) { + // Release this instance's hold on the (potentially huge, for large databases) + // tableColumns/functions arrays so they're actually eligible for GC — see the + // cache-eviction effect below for why `enabled: false` alone isn't enough. + sharedPgInfoRef.current = null } - pgInfoRef.current.tableColumns = tableColumns - pgInfoRef.current.schemas = filteredSchemas - pgInfoRef.current.keywords = keywords - pgInfoRef.current.functions = functions - } + }, [isPgInfoReady, intellisenseEnabled, tableColumns, filteredSchemas, keywords, functions]) + + // Actively evict the cached tableColumns/functions data when intellisense is turned off + useEffect(() => { + if (intellisenseEnabled) return + + queryClient.removeQueries({ + queryKey: databaseKeys.tableColumns(project?.ref, undefined, undefined), + exact: true, + }) + queryClient.removeQueries({ + queryKey: databaseKeys.databaseFunctions(project?.ref), + exact: true, + }) + }, [intellisenseEnabled, project?.ref, queryClient]) // Enable pgsql format useEffect(() => { - if (monaco) { - const formatProvider = monaco.languages.registerDocumentFormattingEditProvider('pgsql', { + if (!monaco || !enabled) return + + return acquireSharedRegistration('pgsql-format', () => + monaco.languages.registerDocumentFormattingEditProvider('pgsql', { async provideDocumentFormattingEdits(model) { const value = model.getValue() const formatted = formatSql(value) @@ -90,31 +153,29 @@ export const useAddDefinitions = ( return [{ range: model.getFullModelRange(), text: formatted }] }, }) - return () => formatProvider.dispose() - } + ) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [monaco]) + }, [monaco, enabled]) // Register auto completion item provider for pgsql useEffect(() => { - let completeProvider: IDisposable | null = null - let signatureHelpProvider: IDisposable | null = null + if (!isPgInfoReady || !monaco) return - if (isPgInfoReady) { - if (monaco && isPgInfoReady) { - completeProvider = monaco.languages.registerCompletionItemProvider( - 'pgsql', - getPgsqlCompletionProvider(monaco, pgInfoRef) - ) - signatureHelpProvider = monaco.languages.registerSignatureHelpProvider( - 'pgsql', - getPgsqlSignatureHelpProvider(monaco, pgInfoRef) - ) + return acquireSharedRegistration('pgsql-completion', () => { + const completeProvider = monaco.languages.registerCompletionItemProvider( + 'pgsql', + getPgsqlCompletionProvider(monaco, sharedPgInfoRef) + ) + const signatureHelpProvider = monaco.languages.registerSignatureHelpProvider( + 'pgsql', + getPgsqlSignatureHelpProvider(monaco, sharedPgInfoRef) + ) + return { + dispose: () => { + completeProvider.dispose() + signatureHelpProvider.dispose() + }, } - } - return () => { - completeProvider?.dispose() - signatureHelpProvider?.dispose() - } + }) }, [isPgInfoReady, monaco]) } diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.test.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.test.tsx index b796ea2e5e0c9..6f01d839212a1 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.test.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.test.tsx @@ -23,11 +23,10 @@ const wireDatabaseCell = (id: string, database_identifier?: string): CellWire => database_identifier, }) -const agentDatabaseCell = (database_identifier?: string): AgentCell => ({ +const agentDatabaseCell = (): AgentCell => ({ _tag: 'database_cell', sql: 'select 1', row_limit: 100, - database_identifier, }) describe('AssistantNotebookPreview', () => { @@ -68,17 +67,17 @@ describe('AssistantNotebookPreview', () => { { _tag: 'replaced', before: wireDatabaseCell('cell-1', 'primary'), - after: agentDatabaseCell('replica-3'), + after: agentDatabaseCell(), operationIndex: 0, }, ] render() - expect(screen.getByText('Database: primary → Database: replica-3')).toBeInTheDocument() + expect(screen.getByText('Database: primary → No metadata')).toBeInTheDocument() expect( screen.getByRole('button', { name: 'Replaced Query: Untitled query' }) - ).toHaveTextContent('Database: primary → Database: replica-3') + ).toHaveTextContent('Database: primary → No metadata') }) it('hides entries past the limit behind a "Show N more" button', async () => { diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts index 9a7536d73bf9a..2d4f52218e878 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts @@ -38,11 +38,10 @@ const wireLogCell = (id: string): CellWire => ({ time_range: { _tag: 'relative_time_range', unit: 'day', amount: 7 }, }) -const agentDatabaseCell = (database_identifier?: string): AgentCell => ({ +const agentDatabaseCell = (): AgentCell => ({ _tag: 'database_cell', sql: 'select 1', row_limit: 100, - database_identifier, }) describe('getEntryKey', () => { @@ -146,26 +145,26 @@ describe('getEntryMetadataLine', () => { ).toBe('Database: replica-3') }) - it('returns a before → after pair when a replacement changes only metadata', () => { + it('returns a before → after pair when a replacement drops the database metadata', () => { expect( getEntryMetadataLine({ _tag: 'replaced', before: wireDatabaseCell('cell-1', 'Signups', 'primary'), - after: agentDatabaseCell('replica-3'), + after: agentDatabaseCell(), operationIndex: 0, }) - ).toBe('Database: primary → Database: replica-3') + ).toBe('Database: primary → No metadata') }) it('returns a single line when replacement metadata is unchanged', () => { expect( getEntryMetadataLine({ _tag: 'replaced', - before: wireDatabaseCell('cell-1', 'Signups', 'primary'), - after: agentDatabaseCell('primary'), + before: wireDatabaseCell('cell-1', undefined, undefined), + after: agentDatabaseCell(), operationIndex: 0, }) - ).toBe('Database: primary') + ).toBe(null) }) }) diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts index b8aefa9bd82f8..72d5e2988252f 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts @@ -83,8 +83,11 @@ export function getCellMetadataLine(cell: OperationResultCell): string | null { switch (cell._tag) { case 'markdown_cell': return null - case 'database_cell': - return cell.database_identifier ? `Database: ${cell.database_identifier}` : null + case 'database_cell': { + const databaseIdentifier = + 'database_identifier' in cell ? cell.database_identifier : undefined + return databaseIdentifier ? `Database: ${databaseIdentifier}` : null + } case 'log_cell': return `Time range: ${formatTimeRange(cell.time_range)}` } diff --git a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx index 5d591f90644da..df86f4795e275 100644 --- a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx @@ -98,33 +98,6 @@ describe('NotebookProposalRenderer', () => { expect(onApprove).toHaveBeenCalledTimes(1) }) - it('warns and withholds the diff when the notebook changed since expected_updated_at', async () => { - const onApprove = vi.fn() - mockContentItem(mockNotebookRow({ updated_at: '2024-06-01T00:00:00.000Z' })) - - render( - - ) - - expect( - await screen.findByText('This notebook changed since the assistant planned this update') - ).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument() - expect(onApprove).not.toHaveBeenCalled() - }) - it('falls back to a raw-input admonition without dropping the confirm footer on a parse failure', async () => { const user = userEvent.setup() const onApprove = vi.fn() diff --git a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx index 0f2c665de5106..e59c5d6aa0681 100644 --- a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx @@ -238,8 +238,6 @@ function UpdateNotebookProposal({ isLoading, isError, error, - refetch, - isFetching, } = useNotebookQuery( { projectRef: ref, id: parsedInput.success ? parsedInput.data.id : undefined }, { enabled: parsedInput.success } @@ -284,30 +282,6 @@ function UpdateNotebookProposal({ ) } - const isStale = notebook.updated_at !== parsedInput.data.expected_updated_at - - if (isStale) { - return ( - refetch()} - > -
- -
-
- ) - } - const diff = deriveNotebookDiff(toWireNotebook(notebook.content), parsedInput.data.operations) if (!diff.success) { diff --git a/apps/studio/data/content/notebooks/notebook-schema.test.ts b/apps/studio/data/content/notebooks/notebook-schema.test.ts index a459382cbe0b4..244d795c45cd4 100644 --- a/apps/studio/data/content/notebooks/notebook-schema.test.ts +++ b/apps/studio/data/content/notebooks/notebook-schema.test.ts @@ -260,6 +260,26 @@ describe('agentNotebookSchema', () => { expect(result.success).toBe(false) }) + + it('rejects a database_cell that carries a database_identifier', () => { + // Agents have no way to discover a project's real read-replica identifiers, so an + // invented one silently breaks the cell (its connection string never resolves) — the + // field is stripped from the agent-facing schema entirely rather than left for a model + // to guess at. + const result = agentNotebookSchema.safeParse({ + schema_version: 1, + cells: [ + { + _tag: 'database_cell', + sql: 'select 1', + row_limit: 100, + database_identifier: 'replica-1', + }, + ], + }) + + expect(result.success).toBe(false) + }) }) describe('writableNotebookSchema', () => { diff --git a/apps/studio/data/content/notebooks/notebook-schema.ts b/apps/studio/data/content/notebooks/notebook-schema.ts index 7d4b058ea76a5..8ac77ee239b7e 100644 --- a/apps/studio/data/content/notebooks/notebook-schema.ts +++ b/apps/studio/data/content/notebooks/notebook-schema.ts @@ -154,11 +154,18 @@ export type WritableNotebook = Omit, 'cel type WritableCellWire = z.infer type WritableNotebookWire = z.infer +// Agents cannot yet target a specific read replica: there's no tool exposing a project's +// real replica identifiers, so a model asked to fill this field has no legitimate value to +// put there — and an invented one silently breaks the cell, since its connection string can +// never resolve (see QueryEditor's run handler). Omit the field entirely until replica +// selection is actually wired up for agents, rather than leave it for a model to guess at. +const agentDatabaseFieldsSchema = databaseFieldsSchema.omit({ database_identifier: true }) + // Agents have restrictions on writing IDs to preserve guarantees about ID // uniqueness export const agentCellSchema = z.discriminatedUnion('_tag', [ markdownFieldsSchema.extend({ _tag: z.literal('markdown_cell') }).strict(), - databaseFieldsSchema.extend({ _tag: z.literal('database_cell') }).strict(), + agentDatabaseFieldsSchema.extend({ _tag: z.literal('database_cell') }).strict(), logFieldsSchema.extend({ _tag: z.literal('log_cell') }).strict(), ]) diff --git a/apps/studio/lib/ai/generate-assistant-response.ts b/apps/studio/lib/ai/generate-assistant-response.ts index 9f457df4bc53f..1488882f504fa 100644 --- a/apps/studio/lib/ai/generate-assistant-response.ts +++ b/apps/studio/lib/ai/generate-assistant-response.ts @@ -2,7 +2,6 @@ import * as ai from 'ai' import { convertToModelMessages, isStepCount, - isToolUIPart, type LanguageModel, type ModelMessage, type SystemModelMessage, @@ -16,6 +15,7 @@ import type { AssistantEvalInput } from '@/evals/scorer' import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' import { buildAssistantContextMessages, NO_SCHEMA_ACCESS_MESSAGE } from '@/lib/ai/assistant-context' import { IS_TRACING_ENABLED } from '@/lib/ai/braintrust-logger' +import { prepareMessagesForModel } from '@/lib/ai/generate-assistant-response.utils' import { CHAT_PROMPT, GENERAL_PROMPT, @@ -23,7 +23,6 @@ import { NOTEBOOKS_PROMPT, SECURITY_PROMPT, } from '@/lib/ai/prompts' -import { sanitizeMessagePart } from '@/lib/ai/tools/tool-sanitizer' const { streamText: tracedStreamText } = wrapAISDK(ai) @@ -74,36 +73,7 @@ export async function generateAssistantResponse({ const shouldTrace = allowTracing ?? IS_TRACING_ENABLED const run = async (span?: Span) => { - // Only returns last 7 messages - // Filters out tools with invalid states - // Filters out tool outputs based on opt-in level - const messages = (rawMessages || []).slice(-7).map((msg) => { - if (msg && msg.role === 'assistant' && 'results' in msg) { - const cleanedMsg = { ...msg } - delete cleanedMsg.results - return cleanedMsg - } - if (msg && msg.role === 'assistant' && msg.parts) { - const cleanedParts = msg.parts - .filter((part) => { - if (isToolUIPart(part)) { - const invalidStates = [ - 'input-streaming', - 'input-available', - 'approval-requested', - 'output-error', - ] - return !invalidStates.includes(part.state) - } - return true - }) - .map((part) => { - return sanitizeMessagePart(part, aiOptInLevel) - }) - return { ...msg, parts: cleanedParts } - } - return msg - }) + const messages = prepareMessagesForModel(rawMessages, aiOptInLevel) const schemasString = aiOptInLevel !== 'disabled' && getSchemas diff --git a/apps/studio/lib/ai/generate-assistant-response.utils.test.ts b/apps/studio/lib/ai/generate-assistant-response.utils.test.ts new file mode 100644 index 0000000000000..7e5d23429df5b --- /dev/null +++ b/apps/studio/lib/ai/generate-assistant-response.utils.test.ts @@ -0,0 +1,125 @@ +import type { ToolUIPart, UIMessage } from 'ai' +import { describe, expect, it } from 'vitest' + +import { prepareMessagesForModel } from './generate-assistant-response.utils' +import { encodeNotebookToolError, NotebookToolError } from './tools/notebook-tools' + +function assistantMessage(parts: UIMessage['parts']): UIMessage { + return { id: 'msg-1', role: 'assistant', parts } +} + +function toolPart(overrides: Partial): ToolUIPart { + return { + type: 'tool-update_notebook', + toolCallId: 'call-1', + state: 'output-available', + input: {}, + ...overrides, + } as ToolUIPart +} + +describe('prepareMessagesForModel', () => { + it('filters out a plain output-error tool part', () => { + const messages = [ + assistantMessage([toolPart({ state: 'output-error', errorText: 'Network error' })]), + ] + + const result = prepareMessagesForModel(messages, 'schema') + + expect(result[0].parts).toEqual([]) + }) + + it('keeps a stale-update_notebook conflict and rewrites errorText to the plain message', () => { + const error = new NotebookToolError('Notebook changed since expected_updated_at', { + exposeToAssistant: true, + }) + const messages = [ + assistantMessage([ + toolPart({ state: 'output-error', errorText: encodeNotebookToolError(error)! }), + ]), + ] + + const result = prepareMessagesForModel(messages, 'schema') + + expect(result[0].parts).toEqual([ + toolPart({ state: 'output-error', errorText: 'Notebook changed since expected_updated_at' }), + ]) + }) + + it('still filters out an unrelated update_notebook output-error', () => { + const messages = [ + assistantMessage([ + toolPart({ state: 'output-error', errorText: 'Unexpected upstream failure' }), + ]), + ] + + const result = prepareMessagesForModel(messages, 'schema') + + expect(result[0].parts).toEqual([]) + }) + + it('still filters out JSON-shaped output-error text lacking the notebook_tool_error tag', () => { + const messages = [ + assistantMessage([ + toolPart({ + state: 'output-error', + errorText: JSON.stringify({ + exposeToAssistant: true, + message: 'not a real notebook error', + }), + }), + ]), + ] + + const result = prepareMessagesForModel(messages, 'schema') + + expect(result[0].parts).toEqual([]) + }) + + it('exposes any tool part carrying a validly-tagged NotebookToolError, not just update_notebook', () => { + const error = new NotebookToolError('Notebook changed since expected_updated_at', { + exposeToAssistant: true, + }) + const messages = [ + assistantMessage([ + toolPart({ + type: 'tool-execute_sql', + state: 'output-error', + errorText: encodeNotebookToolError(error)!, + }), + ]), + ] + + const result = prepareMessagesForModel(messages, 'schema') + + expect(result[0].parts).toEqual([ + toolPart({ + type: 'tool-execute_sql', + state: 'output-error', + errorText: 'Notebook changed since expected_updated_at', + }), + ]) + }) + + it('still filters out input-streaming, input-available, and approval-requested parts', () => { + const messages = [ + assistantMessage([ + toolPart({ state: 'input-streaming' }), + toolPart({ state: 'input-available' }), + toolPart({ state: 'approval-requested', approval: { id: 'a1' } } as Partial), + ]), + ] + + const result = prepareMessagesForModel(messages, 'schema') + + expect(result[0].parts).toEqual([]) + }) + + it('keeps non-tool parts untouched', () => { + const messages = [assistantMessage([{ type: 'text', text: 'hello' }])] + + const result = prepareMessagesForModel(messages, 'schema') + + expect(result[0].parts).toEqual([{ type: 'text', text: 'hello' }]) + }) +}) diff --git a/apps/studio/lib/ai/generate-assistant-response.utils.ts b/apps/studio/lib/ai/generate-assistant-response.utils.ts new file mode 100644 index 0000000000000..3d2851656ec61 --- /dev/null +++ b/apps/studio/lib/ai/generate-assistant-response.utils.ts @@ -0,0 +1,50 @@ +import { isToolUIPart, type DynamicToolUIPart, type ToolUIPart, type UIMessage } from 'ai' + +import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' +import { decodeNotebookToolError } from '@/lib/ai/tools/notebook-tools' +import { sanitizeMessagePart } from '@/lib/ai/tools/tool-sanitizer' + +const INVALID_TOOL_STATES = [ + 'input-streaming', + 'input-available', + 'approval-requested', + 'output-error', +] + +/** + * Notebook tool errors are opted into model visibility explicitly (NotebookToolError, + * encoded in generate-v4.ts's onError). A successful parse against the schema — including + * its literal `tag` — is proof enough that this was a NotebookToolError; every other + * output-error stays hidden, same as before. Rewrites errorText back to the plain message + * so the model sees prose, not JSON. + */ +function exposedNotebookErrorPart( + part: ToolUIPart | DynamicToolUIPart +): ToolUIPart | DynamicToolUIPart | null { + if (part.state !== 'output-error') return null + const decoded = decodeNotebookToolError(part.errorText) + if (!decoded?.exposeToAssistant) return null + return { ...part, errorText: decoded.message } +} + +/** Trims history to the last 7 messages and strips tool parts the model shouldn't see. */ +export function prepareMessagesForModel(rawMessages: UIMessage[], aiOptInLevel: AiOptInLevel) { + return (rawMessages || []).slice(-7).map((msg) => { + if (msg && msg.role === 'assistant' && 'results' in msg) { + const cleanedMsg = { ...msg } + delete cleanedMsg.results + return cleanedMsg + } + if (msg && msg.role === 'assistant' && msg.parts) { + const cleanedParts = msg.parts.flatMap((part) => { + if (!isToolUIPart(part)) return [part] + if (!INVALID_TOOL_STATES.includes(part.state)) + return [sanitizeMessagePart(part, aiOptInLevel)] + const exposed = exposedNotebookErrorPart(part) + return exposed ? [sanitizeMessagePart(exposed, aiOptInLevel)] : [] + }) + return { ...msg, parts: cleanedParts } + } + return msg + }) +} diff --git a/apps/studio/lib/ai/tools/notebook-tools.test.ts b/apps/studio/lib/ai/tools/notebook-tools.test.ts index 2650d326ca044..fb692afbf20b6 100644 --- a/apps/studio/lib/ai/tools/notebook-tools.test.ts +++ b/apps/studio/lib/ai/tools/notebook-tools.test.ts @@ -2,7 +2,12 @@ import { components } from 'api-types' import { HttpResponse } from 'msw' import { describe, expect, it } from 'vitest' -import { getNotebookTools } from './notebook-tools' +import { + decodeNotebookToolError, + encodeNotebookToolError, + getNotebookTools, + NotebookToolError, +} from './notebook-tools' import type { AgentNotebook } from '@/data/content/notebooks/notebook-schema' import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw' @@ -432,40 +437,80 @@ describe('ai/tools/notebook-tools', () => { expect(result).toEqual({ id: 'notebook-1', name: 'Signup funnel' }) }) - it('should throw a descriptive error instead of PUTting when an operation targets an unknown cell id', async () => { + it('should throw a descriptive, assistant-exposable error instead of PUTting when an operation targets an unknown cell id', async () => { mockGetNotebook() const tools = getNotebookTools({ projectRef: 'test-project' }) if (!tools.update_notebook.execute) throw new Error('execute is undefined') - await expect( - tools.update_notebook.execute( - { - id: 'notebook-1', - expected_updated_at: '2026-01-01T00:00:00.000Z', - operations: [{ _tag: 'delete_cell', cell_id: 'missing-cell' }], - }, - { toolCallId: 'test', messages: [], context: {} } - ) - ).rejects.toThrow('No cell with id "missing-cell"') + const execute = tools.update_notebook.execute( + { + id: 'notebook-1', + expected_updated_at: '2026-01-01T00:00:00.000Z', + operations: [{ _tag: 'delete_cell', cell_id: 'missing-cell' }], + }, + { toolCallId: 'test', messages: [], context: {} } + ) + + await expect(execute).rejects.toThrow('No cell with id "missing-cell"') + await expect(execute).rejects.toBeInstanceOf(NotebookToolError) + await expect(execute).rejects.toMatchObject({ metadata: { exposeToAssistant: true } }) }) - it('should throw instead of PUTting when the notebook changed since expected_updated_at', async () => { + it('should throw an assistant-exposable error instead of PUTting when the notebook changed since expected_updated_at', async () => { mockGetNotebook() const tools = getNotebookTools({ projectRef: 'test-project' }) if (!tools.update_notebook.execute) throw new Error('execute is undefined') - await expect( - tools.update_notebook.execute( - { - id: 'notebook-1', - expected_updated_at: '2025-12-31T00:00:00.000Z', - operations: [{ _tag: 'delete_cell', cell_id: 'cell-3' }], - }, - { toolCallId: 'test', messages: [], context: {} } - ) - ).rejects.toThrow(/changed since expected_updated_at/) + const execute = tools.update_notebook.execute( + { + id: 'notebook-1', + expected_updated_at: '2025-12-31T00:00:00.000Z', + operations: [{ _tag: 'delete_cell', cell_id: 'cell-3' }], + }, + { toolCallId: 'test', messages: [], context: {} } + ) + + await expect(execute).rejects.toThrow(/changed since expected_updated_at/) + await expect(execute).rejects.toBeInstanceOf(NotebookToolError) + await expect(execute).rejects.toMatchObject({ metadata: { exposeToAssistant: true } }) + }) + }) + + describe('encodeNotebookToolError / decodeNotebookToolError', () => { + it('round-trips a NotebookToolError through JSON', () => { + const error = new NotebookToolError('Notebook changed since expected_updated_at', { + exposeToAssistant: true, + }) + + const encoded = encodeNotebookToolError(error) + expect(encoded).not.toBeNull() + + const decoded = decodeNotebookToolError(encoded!) + expect(decoded).toEqual({ + exposeToAssistant: true, + tag: 'notebook_tool_error', + message: 'Notebook changed since expected_updated_at', + }) + }) + + it('does not encode a plain Error', () => { + expect(encodeNotebookToolError(new Error('boom'))).toBeNull() + }) + + it('does not decode a plain error message', () => { + expect(decodeNotebookToolError('boom')).toBeNull() + }) + + it('does not decode unrelated JSON', () => { + expect(decodeNotebookToolError(JSON.stringify({ foo: 'bar' }))).toBeNull() + }) + + it('does not decode JSON that merely looks like a NotebookToolError but lacks the tag', () => { + expect( + decodeNotebookToolError(JSON.stringify({ exposeToAssistant: true, message: 'boom' })) + ).toBeNull() }) }) }) diff --git a/apps/studio/lib/ai/tools/notebook-tools.ts b/apps/studio/lib/ai/tools/notebook-tools.ts index 1ec84bdb88dae..2c4498d459e85 100644 --- a/apps/studio/lib/ai/tools/notebook-tools.ts +++ b/apps/studio/lib/ai/tools/notebook-tools.ts @@ -24,6 +24,54 @@ export type NotebookToolsContext = { authorization?: string } +const notebookToolErrorMetadataSchema = z.object({ + exposeToAssistant: z.boolean(), +}) + +export type NotebookToolErrorMetadata = z.infer + +const notebookToolErrorSchema = notebookToolErrorMetadataSchema.extend({ + tag: z.literal('notebook_tool_error'), + message: z.string(), +}) + +export type EncodedNotebookToolError = z.infer + +/** Thrown by update_notebook for failures the assistant can act on by retrying. */ +export class NotebookToolError extends Error { + readonly metadata: NotebookToolErrorMetadata + + constructor(message: string, metadata: NotebookToolErrorMetadata) { + super(message) + this.name = 'NotebookToolError' + this.metadata = notebookToolErrorMetadataSchema.parse(metadata) + } +} + +/** Called from the stream's `onError` (generate-v4.ts), which still has the live Error. */ +export function encodeNotebookToolError(error: unknown): string | null { + if (!(error instanceof NotebookToolError)) return null + return JSON.stringify( + notebookToolErrorSchema.parse({ + ...error.metadata, + tag: 'notebook_tool_error', + message: error.message, + }) + ) +} + +/** Called from the history filter (generate-assistant-response.ts) on a persisted errorText. */ +export function decodeNotebookToolError(errorText: string): EncodedNotebookToolError | null { + let parsed: unknown + try { + parsed = JSON.parse(errorText) + } catch { + return null + } + const result = notebookToolErrorSchema.safeParse(parsed) + return result.success ? result.data : null +} + export const getNotebookTools = (ctx: NotebookToolsContext = {}) => { const { projectRef, authorization } = ctx const authHeaders = authorization ? { Authorization: authorization } : undefined @@ -153,8 +201,9 @@ export const getNotebookTools = (ctx: NotebookToolsContext = {}) => { const notebook = await getNotebook({ projectRef, id }, undefined, authHeaders) if (notebook.updated_at !== expected_updated_at) { - throw new Error( - `Notebook "${id}" changed since expected_updated_at (${expected_updated_at}); it is now ${notebook.updated_at}. Call get_notebook again and reissue update_notebook against the current content.` + throw new NotebookToolError( + `Notebook "${id}" changed since expected_updated_at (${expected_updated_at}); it is now ${notebook.updated_at}. Call get_notebook again and reissue update_notebook against the current content.`, + { exposeToAssistant: true } ) } @@ -165,7 +214,9 @@ export const getNotebookTools = (ctx: NotebookToolsContext = {}) => { const result = applyNotebookOperations(wireNotebook, operations) if (!result.success) { - throw new Error(describeNotebookOperationError(result.error)) + throw new NotebookToolError(describeNotebookOperationError(result.error), { + exposeToAssistant: true, + }) } // Same promotion as create_notebook above, inlined here for the same auditability diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts index d8900ab88db77..1b056af7c6440 100644 --- a/apps/studio/pages/api/ai/sql/generate-v4.ts +++ b/apps/studio/pages/api/ai/sql/generate-v4.ts @@ -26,6 +26,7 @@ import { type AssistantModelId, } from '@/lib/ai/model.utils' import { getTools } from '@/lib/ai/tools' +import { encodeNotebookToolError } from '@/lib/ai/tools/notebook-tools' import { apiWrapper } from '@/lib/api/apiWrapper' import { executeQuery } from '@/lib/api/self-hosted/query' import { getURL } from '@/lib/helpers' @@ -250,6 +251,9 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw onError: (error) => { console.error('Assistant stream error:', error) + const encoded = encodeNotebookToolError(error) + if (encoded !== null) return encoded + if (error == null) { return 'unknown error' } diff --git a/apps/www/components/LaunchWeek/11/LW11Summary.tsx b/apps/www/components/LaunchWeek/11/LW11Summary.tsx index 1fc7ecd83484a..8cdbec064eed9 100644 --- a/apps/www/components/LaunchWeek/11/LW11Summary.tsx +++ b/apps/www/components/LaunchWeek/11/LW11Summary.tsx @@ -38,14 +38,14 @@ const LW11Summary = () => { {days.map( (day, i: number) => day.shipped && ( -
    +
  1. Day {i + 1} - {day.title} -
+ ) )} @@ -59,7 +59,7 @@ const LW11Summary = () => { {buildDays.map( (day, i: number) => day.is_shipped && ( -
    +
  1. { {day.title} -
+ ) )} -
    +
  1. Open Source Hackathon 2024 -
-
    + +
  1. Community Meetups -
+
diff --git a/apps/www/components/LaunchWeek/12/LWSummary.tsx b/apps/www/components/LaunchWeek/12/LWSummary.tsx index eeba78750a53c..2a50107df3a3d 100644 --- a/apps/www/components/LaunchWeek/12/LWSummary.tsx +++ b/apps/www/components/LaunchWeek/12/LWSummary.tsx @@ -22,14 +22,14 @@ const LW11Summary = () => { {days.map( (day, i: number) => day.shipped && ( -
    +
  1. Day {i + 1} - {day.title} -
+ ) )} @@ -43,7 +43,7 @@ const LW11Summary = () => { {buildDays.map( (day, i: number) => day.is_shipped && ( -
    +
  1. { {day.title} -
+ ) )} -
    +
  1. Community Meetups -
+ diff --git a/apps/www/components/LaunchWeek/13/Releases/LWSummary.tsx b/apps/www/components/LaunchWeek/13/Releases/LWSummary.tsx index 67503d47dfd6d..c0b20d9b064c4 100644 --- a/apps/www/components/LaunchWeek/13/Releases/LWSummary.tsx +++ b/apps/www/components/LaunchWeek/13/Releases/LWSummary.tsx @@ -22,14 +22,14 @@ const LW13Summary = () => { {days.map( (day, i: number) => day.shipped && ( -
    +
  1. Day {i + 1} - {day.title} -
+ ) )} @@ -43,7 +43,7 @@ const LW13Summary = () => { {buildDays.map( (day, i: number) => day.is_shipped && ( -
    +
  1. { {day.title} -
+ ) )} -
    +
  1. Community Meetups -
+ diff --git a/apps/www/components/LaunchWeek/14/Releases/LWSummary.tsx b/apps/www/components/LaunchWeek/14/Releases/LWSummary.tsx index 1b8611af83d0f..cc6d67f678dd1 100644 --- a/apps/www/components/LaunchWeek/14/Releases/LWSummary.tsx +++ b/apps/www/components/LaunchWeek/14/Releases/LWSummary.tsx @@ -26,12 +26,12 @@ const LW14Summary = () => { {days.map( (day, i: number) => day.shipped && ( -
    +
  1. Day {i + 1} - {day.title} -
+ ) )} @@ -43,7 +43,7 @@ const LW14Summary = () => { {buildDays.map( (day, i: number) => day.is_shipped && ( -
    +
  1. { {day.title} -
+ ) )} -
    +
  1. Community Meetups -
+ diff --git a/apps/www/components/LaunchWeek/15/LWSummary.tsx b/apps/www/components/LaunchWeek/15/LWSummary.tsx index d56ff6237c621..f62c2b5a1f4e2 100644 --- a/apps/www/components/LaunchWeek/15/LWSummary.tsx +++ b/apps/www/components/LaunchWeek/15/LWSummary.tsx @@ -27,7 +27,7 @@ const LW14Summary = () => {
    {days.map((day, i: number) => day.shipped ? ( -
      +
    1. { - {day.title} -
    + ) : ( -
      +
    1. { - -
    + ) )}
@@ -58,7 +58,7 @@ const LW14Summary = () => {
    {buildDays.map((day, i: number) => day.is_shipped ? ( -
      +
    1. { {day.title} -
    + ) : ( -
      +
    1. { -
    + ) )} -
      +
    1. Community Meetups -
    +
diff --git a/apps/www/components/LaunchWeek/6/lw6_days.json b/apps/www/components/LaunchWeek/6/lw6_days.json deleted file mode 100644 index 14ab538066481..0000000000000 --- a/apps/www/components/LaunchWeek/6/lw6_days.json +++ /dev/null @@ -1,114 +0,0 @@ -[ - { - "title": "Docs Updated", - "shipped": true, - "date": "12 Dec 2022", - "description": "Supabase Docs", - "d": 1, - "dd": "Monday", - "youtube_id": "OpPOaJI_Z28", - "blogpost": "https://supabase.com/blog/new-supabase-docs-built-with-nextjs", - "docs": "https://supabase.com/docs", - "steps": [ - { - "title": "Supabase Docs", - "blog": "/blog/new-supabase-docs-built-with-nextjs", - "docs": "/docs", - "isNew": true, - "description": "" - } - ] - }, - { - "title": "Image Processing: Resize V2 + CDN", - "shipped": true, - "date": "13 Dec 2022", - "description": "Image Transformations", - "d": 2, - "dd": "Tuesday", - "youtube_id": "iqZlPtl_b-I", - "blogpost": "https://supabase.com/blog", - "docs": "https://supabase.com/docs", - "steps": [ - { - "title": "Image Transformations", - "blog": "/blog/storage-image-resizing-smart-cdn", - "docs": "/docs/guides/storage/cdn/fundamentals", - "isNew": true, - "description": "" - }, - { - "title": "Smart CDN", - "description": "Faster asset delivery, now even faster.", - "blog": "/blog/storage-image-resizing-smart-cdn", - "docs": "/docs/guides/storage/cdn/fundamentals", - "isNew": true - } - ] - }, - { - "title": "Multi-factor Authentication", - "shipped": true, - "date": "14 Dec 2022", - "description": "Multi-factor Authentication", - "d": 3, - "dd": "Wednesday", - "youtube_id": "CGZr5tybW18", - "blogpost": "https://supabase.com/blog", - "docs": "https://supabase.com/docs", - "steps": [ - { - "title": "Multi-factor Authentication", - "blog": "/blog/mfa-auth-via-rls", - "docs": "/docs/guides/auth/auth-mfa", - "isNew": false, - "description": "" - } - ] - }, - { - "title": "Supabase Wrappers - FDW Framework", - "shipped": true, - "date": "15 Dec 2022", - "description": "Supabase Wrappers", - "d": 4, - "dd": "Thursday", - "youtube_id": "QA2qC5F-4OU", - "blogpost": "/blog/postgres-foreign-data-wrappers-rust", - "docs": "https://supabase.com/docs", - "steps": [ - { - "title": "Supabase Wrappers", - "blog": "/blog/postgres-foreign-data-wrappers-rust", - "docs": "https://supabase.github.io/wrappers/", - "isNew": true, - "description": "Connect Postgres to external systems with Postgres Foreign Data Wrappers" - } - ] - }, - { - "title": "Supabase Vault Release", - "shipped": true, - "date": "16 Dec 2022", - "description": "Vault Release", - "d": 5, - "dd": "Friday", - "youtube_id": "6bGQotxisoY", - "blogpost": "https://supabase.com/blog", - "docs": "https://supabase.com/docs", - "steps": [ - { - "title": "Vault Release", - "blog": "/blog/vault-now-in-beta", - "isNew": true, - "description": "" - }, - { - "title": "Transparent Column Encryption", - "description": "Faster asset delivery, now even faster.", - "blog": "/blog/transparent-column-encryption-with-postgres", - "isNew": true - } - ] - } -] diff --git a/apps/www/components/LaunchWeek/8/LWArchive.tsx b/apps/www/components/LaunchWeek/8/LWArchive.tsx index 38a88417eb435..44aeec837e68f 100644 --- a/apps/www/components/LaunchWeek/8/LWArchive.tsx +++ b/apps/www/components/LaunchWeek/8/LWArchive.tsx @@ -1,6 +1,7 @@ +import Image from 'next/image' import React from 'react' + import { StyledArticleBadge } from './Releases/components' -import Image from 'next/image' const LWArchive = () => { return ( @@ -13,23 +14,6 @@ const LWArchive = () => {

- - - 6 - -
- Launch Week 6 -
-
{ {mainDays.map( (day, i: number) => day.shipped && ( -
    +
  1. Day {i + 1} - {day.description} -
+ ) )} @@ -51,7 +51,7 @@ const LWXSummary = () => { {buildDays.map( (day, i: number) => day.is_shipped && ( -
    +
  1. { {day.title} -
+ ) )} -
    +
  1. Supabase Launch Week X Hackathon -
-
    + +
  1. Supabase Launch Week X Community Meetups -
+
diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js index c19d0d3d97cd9..7aebc0c9000ec 100644 --- a/apps/www/lib/redirects.js +++ b/apps/www/lib/redirects.js @@ -2937,6 +2937,11 @@ module.exports = [ source: '/launchweek', destination: '/launch-week', }, + { + permanent: true, + source: '/launch-week/6', + destination: '/blog/launch-week-6-wrap-up', + }, { permanent: true, source: '/docs/guides/platform/enterprise-billing', diff --git a/apps/www/pages/launch-week/6/index.tsx b/apps/www/pages/launch-week/6/index.tsx deleted file mode 100644 index 7e3863680d6d3..0000000000000 --- a/apps/www/pages/launch-week/6/index.tsx +++ /dev/null @@ -1,1085 +0,0 @@ -// @ts-nocheck - -import { createClient, SupabaseClient } from '@supabase/supabase-js' -import _days from '~/components/LaunchWeek/6/lw6_days.json' -import DefaultLayout from '~/components/Layouts/Default' -import SectionContainer from '~/components/Layouts/SectionContainer' -import { SITE_ORIGIN } from '~/lib/constants' -import type { WeekDayProps } from '~/types/launch-week-6' -import classNames from 'classnames' -import { ExternalLink } from 'lucide-react' -import { NextSeo } from 'next-seo' -import { useTheme } from 'next-themes' -import Image from 'next/image' -import { useRouter } from 'next/router' -import { useEffect, useState } from 'react' -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Badge } from 'ui' - -import styles from './styles/launchWeek6.module.css' -import styleUtils from './styles/utils6.module.css' - -const days = _days as WeekDayProps[] -const constellation = [ - [60, 8], - [13, 20], - [42, 24], - [68, 27], - [23, 42], - [52, 52], - [0, 55], - [33, 65], - [66, 70], - [55, 82], -] - -export default function launchweek() { - const { resolvedTheme } = useTheme() - const title = 'Launch Week 6' - const description = 'Supabase Launch Week 6 | 12-18 Dec 2022' - const liveDay = null - - const [supabase, setSupabase] = useState(null) - - const [creators, setCreators] = useState([]) - const [activeCreator, setActiveCreator] = useState(null) - const { query } = useRouter() - const ticketNumber = query.ticketNumber?.toString() - - useEffect(() => { - if (!supabase) { - setSupabase( - createClient( - process.env.NEXT_PUBLIC_MISC_USE_URL!, - process.env.NEXT_PUBLIC_MISC_USE_ANON_KEY! - ) - ) - } - }, []) - - useEffect(() => { - if (supabase) { - getCreators() - } - }, [supabase]) - - async function getCreators() { - try { - // setLoading(true) - let supa = await supabase.from('lw6_creators').select() - - let { data, error, status } = supa - - if (error && status !== 406) { - throw error - } - - if (data) { - setCreators(data) - } - } catch (error) { - // alert('Error loading user data!') - console.log(error) - } finally { - // setLoading(false) - } - } - - const AccordionHeader = ({ date, day, title, shipped }: any) => { - return ( -
-
- - {shipped ? 'Shipped' : 'Coming Soon'} - - - - {day} ・ {date} - -
- {title} -
- ) - } - - const SectionButtons = ({ blog, docs, video }) => { - return ( -
- -
- Blog post -
- -
-
-
- {docs && ( - -
- Docs -
- -
-
-
- )} - {video && ( - -
- Video -
- -
-
-
- )} -
- ) - } - const [day1, day2, day3, day4, day5] = days - - return ( - <> - - - -
-
- -
-

- Dec 12 – 16 at 6 AM PT | 9 AM ET -

-
-
-
-
-
- -
-
-
- - -
-
-
- -
-
- Who we hire at Supabase - Fireside chat with founders -
-
- -
-
- -
-
-
- -
-
- Wrap Up - Everything we shipped -
-
- -
-
- - - - - - - - {day1.steps.length > 0 && ( -
-
-
-
- {day1.description} - - Redesigned - -
- -
-
- )} -
-
- - - - - - {day2.steps.length > 0 && ( -
-
-
-
-
{day2.steps[0].title}
- - New - -
- -
-
-
-
- - New - - {day2.steps[1].title} -

{day2.steps[1].description}

-
- -
-
- )} -
-
- - - - - - {day3.steps.length > 0 && ( -
-
-
- -
-
- -
- -
- {day3.steps[0].title} - - Updated - -
- -
-
- )} -
-
- - - - - - {day4.steps.length > 0 && ( -
-
-
- -
-
- -
-
- {day4.steps[0].title} - - New - -
- -
-
- )} -
-
- - - - - - {day5.steps.length > 0 && ( - <> -
-
-
- -
-
- -
-
- {day5.steps[0].title} - - New - -
- -
-
-
- -
-
- -
-
- - New - - {day5.steps[1].title} -
- -
-
-

Community

- -

One more thing

-
-
-
- -
-
- -
-
- - Updated - - pg_graphql v1.0 -
- -
-
-
- -
-
- -
-
- - New - - Custom Domains -
- -
-
-
- -
-
- -
-
- - New - - Point-in-time recovery -
- -
-
-
- -
-
- -
-
- - Experimental - - pg_crdt -
- -
-
-
- -
-
- -
-
- - Upgrade - - Postgres 15 -
- -
-
-
- -
-
- -
-
- - Upgrade - - PostgREST 11 -
- -
-
- - )} -
-
-
-
- - - - - -
- - Submissions Closed - -

Launch Week Hackathon

-

- The traditional parallel Hackathon is back! Build a new open source project with - Supabase and you can win $1500 in GitHub sponsorships and a coveted Supabase Darkmode - Keyboard! For more info check the{' '} - - blog post - - . -

-
-
-
-
-

Prizes

-

- There are 5 categories to win, with prizes for winners and runner-ups of each - category. Each team member gets a prize. -

-
-
-

Judges

-

- The Supabase team will judge all the categories except the Best Edge Functions - Project, which will be judged by our friends at Deno. -

-
-
-
-

Community

-

- If you need help or advice when building, find other people to join your team, - or if you just want to chill and watch people build, come and join us! -

-
- - Join our Discord - -
-
-
-

Submission

-

- Submit your project through{' '} - - madewithsupabase.com - - . All submissions must be open source and publically available. Submissions close - Monday 19th Dec 00:01 AM PT. -

-
-
- -
-
- -
- {creators.map((creator: any, index: number) => { - return ( -
{ - setActiveCreator(index) - }} - style={{ - top: `${constellation[index][0]}%`, - left: `${constellation[index][1]}%`, - }} - > -
-
- - - -
-
- ) - })} -
-
- - Shipped - -

The Supabase Content Storm

-

- We worked with more than 30 content creators from around the world to drop a mountain - of content simultaneously! - - See all the content - - -

- {activeCreator !== null && ( -
-

- {activeCreator !== null - ? `${creators[activeCreator].first_name} ${creators[activeCreator].last_name}` - : 'Title'} -

- {activeCreator !== null && ( -

{creators[activeCreator].description}

- )} -

- - - {creators[activeCreator].link_title} - - - -

-
- )} -
-
-
- - ) -} diff --git a/apps/www/pages/launch-week/6/styles/launchWeek6.module.css b/apps/www/pages/launch-week/6/styles/launchWeek6.module.css deleted file mode 100644 index d31924eece8e4..0000000000000 --- a/apps/www/pages/launch-week/6/styles/launchWeek6.module.css +++ /dev/null @@ -1,30 +0,0 @@ -.mask { - mask-image: radial-gradient(black, transparent); -} - -.dark_community { - background-image: radial-gradient( - closest-side at 50% 50%, - #132121, - #132121db, - var(--background-default) - ); -} - -.community { - background-image: radial-gradient(closest-side at 50% 50%, #d9eeef, #dbeef0, white); -} - -.wrappers > span { - left: -200px !important; -} - -.community_wrappers > span { - left: -400px !important; -} - -@media (max-width: 768px) { - .community_wrappers > span > img { - object-fit: contain !important; - } -} diff --git a/apps/www/pages/launch-week/6/styles/utils6.module.css b/apps/www/pages/launch-week/6/styles/utils6.module.css deleted file mode 100644 index c4aba2ab6861c..0000000000000 --- a/apps/www/pages/launch-week/6/styles/utils6.module.css +++ /dev/null @@ -1,76 +0,0 @@ -.appear { - opacity: 0; - animation: appear 0.8s cubic-bezier(0.1, 0, 0.175, 1) forwards; -} - -.appear-first { - animation-delay: 0.4s; -} - -.appear-second { - animation-delay: 0.8s; -} - -.appear-third { - animation-delay: 1.2s; -} - -.appear-fourth { - animation-delay: 1.6s; -} - -.appear-fifth { - animation-delay: 2s; -} - -.appear-sixth { - animation-delay: 2.4s; -} - -@keyframes appear { - to { - opacity: 1; - } -} - -@media (min-width: 1200px) { - .hide-on-desktop { - display: none; - } -} - -.show-on-mobile { - display: block; -} - -.hide-on-mobile { - display: none; -} - -@media (min-width: 768px) { - .show-on-mobile { - display: none; - } - - .hide-on-mobile { - display: block; - } -} - -.hide-on-tablet { - display: block; -} - -.show-on-tablet { - display: none; -} - -@media (max-width: 1199px) and (min-width: 768px) { - .show-on-tablet { - display: block; - } - - .hide-on-tablet { - display: none; - } -} diff --git a/apps/www/types/launch-week-6.ts b/apps/www/types/launch-week-6.ts deleted file mode 100644 index fbea7cf9d0f2f..0000000000000 --- a/apps/www/types/launch-week-6.ts +++ /dev/null @@ -1,43 +0,0 @@ -export type Article = { - title: string - url: string - description?: string - products?: Product[] -} - -export type Announcement = { - title: string - url: string - description?: string - type: 'soc2' | 'producthunt' -} - -export type Product = { - title: string - url: string - description?: string -} - -export type Step = { - title: string - url: string - docs: string - description?: string -} - -export interface WeekDayProps { - shipped: boolean - title: string - description: string - date: string - imgUrl?: string - d?: number - dd?: 'Monday' | 'Tuesday' | 'Wednesday' | 'Thursday' | 'Friday' - articles?: Article[] - announcements?: Announcement[] - products?: Announcement[] - index: number - shippingHasStarted?: boolean - youtube_id?: string - steps: Step[] -}