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 && (
-
+
Day {i + 1} -
{day.title}
-
+
)
)}
@@ -59,7 +59,7 @@ const LW11Summary = () => {
{buildDays.map(
(day, i: number) =>
day.is_shipped && (
-
+
{
{day.title}
-
+
)
)}
-
+
Open Source Hackathon 2024
-
-
+
+
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 && (
-
+
Day {i + 1} -
{day.title}
-
+
)
)}
@@ -43,7 +43,7 @@ const LW11Summary = () => {
{buildDays.map(
(day, i: number) =>
day.is_shipped && (
-
+
{
{day.title}
-
+
)
)}
-
+
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 && (
-
+
Day {i + 1} -
{day.title}
-
+
)
)}
@@ -43,7 +43,7 @@ const LW13Summary = () => {
{buildDays.map(
(day, i: number) =>
day.is_shipped && (
-
+
{
{day.title}
-
+
)
)}
-
+
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 && (
-
+
Day {i + 1} -
{day.title}
-
+
)
)}
@@ -43,7 +43,7 @@ const LW14Summary = () => {
{buildDays.map(
(day, i: number) =>
day.is_shipped && (
-
+
{
{day.title}
-
+
)
)}
-
+
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 ? (
-
+
{
-
{day.title}
-
+
) : (
-
+
{
-
-
+
)
)}
@@ -58,7 +58,7 @@ const LW14Summary = () => {
{buildDays.map((day, i: number) =>
day.is_shipped ? (
-
+
{
{day.title}
-
+
) : (
-
+
{
-
+
)
)}
-
+
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 = () => {
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 (
-
- )
- }
- 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
-
-
-
-
-
-
-
-
-
- Community Day
-
-
-
-
- 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[]
-}