diff --git a/.github/labeler.yml b/.github/labeler.yml index be2f10f17c872..ee8e16372e68e 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,5 +1,10 @@ # Add 'documentation' to any change in apps/docs # https://github.com/marketplace/actions/labeler documentation: -- changed-files: - - any-glob-to-any-file: 'apps/docs/**/*' + - changed-files: + - any-glob-to-any-file: 'apps/docs/**/*' + +# Add 'api-deploy-required' to any change in packages/api-types/types +api-deploy-required: + - changed-files: + - any-glob-to-any-file: 'packages/api-types/types/**' diff --git a/.github/workflows/label_prs.yml b/.github/workflows/label_prs.yml index 741ddea4ac724..7bd90fd102e72 100644 --- a/.github/workflows/label_prs.yml +++ b/.github/workflows/label_prs.yml @@ -1,10 +1,7 @@ name: 'Pull Request Labeler' -# only docs uses the labeler at the moment on: pull_request_target: - paths: - - 'apps/docs/**/*' jobs: labeler: @@ -13,4 +10,17 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 + - id: label + uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 + + - name: Comment when api-deploy-required is auto-applied + if: contains(steps.label.outputs.new-labels, 'api-deploy-required') + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: 'The `api-deploy-required` label was auto-applied to this PR because it updates the API types. Ensure that the new or updated API, if any, is deployed on production before **removing the label** and merging this PR.', + }) diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index 1b10f526421d9..2ee49d84c8e90 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -17,6 +17,12 @@ jobs: echo "PR blocked: [tag: do not merge]" exit 1 + - name: Tagged with 'api-deploy-required' + if: contains( github.event.pull_request.labels.*.name, 'api-deploy-required') + run: | + echo "PR blocked: [tag: api-deploy-required] — confirm the API is deployed in production, then remove the label." + exit 1 + - name: All good if: ${{ success() }} run: | diff --git a/apps/docs/content/guides/realtime/protocol.mdx b/apps/docs/content/guides/realtime/protocol.mdx index b823ffc50da37..5170749dfc2a5 100644 --- a/apps/docs/content/guides/realtime/protocol.mdx +++ b/apps/docs/content/guides/realtime/protocol.mdx @@ -478,7 +478,7 @@ Example on protocol version `2.0.0`: #### phx_error -This message is sent by the server when an unexpected error occurs in the channel. Payload will be an empty object +This message is sent by the server when the channel process terminates unexpectedly. Payload will be an empty object. See [Reconnection](#reconnection) for recovery guidance. ```json ["3", "3", "realtime:avatar-stack-demo", "phx_error", {}] @@ -500,7 +500,7 @@ The server sends these messages in response to client requests that require ackn - `status`: The status of the response, can be `ok` or `error`. - `response`: The response data, which can vary based on the event that was replied to -`phx_join` has a specific response structure outlined below. +`phx_join` has a specific response structure outlined below. When a join is rejected, `status` is `"error"` — see [Join errors](#join-errors) for the full list of error codes and recovery actions. Contains the status of the join request and any additional information requested in the `phx_join` payload. @@ -551,7 +551,7 @@ Example on protocol version `2.0.0`: #### system -The server sends system messages to inform clients about the status of their Realtime channel subscriptions. +The server sends system messages to inform clients about the status of their Realtime channel subscriptions. See [Channel-level system errors](#channel-level-system-errors) for the full list of messages and recovery actions. ```ts { @@ -888,3 +888,100 @@ Example on protocol version `2.0.0`: } ] ``` + +## Error handling + +Errors arrive on four channels: + +- A WebSocket close frame before the channel joins. +- A `phx_reply` with `status: "error"` rejecting a `phx_join` or push. +- A `system` event on a live channel — channel-level system errors are always followed by `phx_close`, while `postgres_changes` system errors are informational and leave the channel open. +- A `phx_error` when the channel process terminates unexpectedly. + +{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */} + +### Join errors + +When a `phx_join` is rejected, the `phx_reply` payload carries `response.reason` as `": "`. The server adds a backoff delay before replying, so avoid aggressive client-side retry loops on join errors. + +```json +{ + "status": "error", + "response": { "reason": "InvalidJWTExpiration: Token has expired 300 seconds ago" } +} +``` + +One exception: the `UnknownErrorOnChannel` code arrives as the bare human-readable string `"Unknown Error on Channel"` without the `: ` prefix. The JS client exposes the full `reason` string directly as the Error message without parsing it further. + +| Category | Error codes | Action | +| -------------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------- | +| Auth — expired token | `InvalidJWTExpiration` (message contains `"expired"`) | Refresh token, rejoin | +| Auth — invalid token | `MalformedJWT`, `JwtSignatureError`, `Unauthorized` | Do not retry; surface to caller | +| Rate limit | `ConnectionRateLimitReached`, `ClientJoinRateLimitReached`, `ChannelRateLimitReached` | Backoff, reduce join frequency | +| Database | `InitializingProjectConnection`, `IncreaseConnectionPool`, `DatabaseLackOfConnections`, `UnableToConnectToProject` | Retry with exponential backoff | +| Config | `TopicNameRequired`, `TenantNotFound`, `RealtimeDisabledForTenant`, `RealtimeDisabledForConfiguration` | Do not retry | +| Transient | `RealtimeRestarting` | Retry with backoff | + +### Channel-level system errors + +`extension: "system"`, `status: "error"`. Match on the `message` field content — there is no machine-readable code field. Every channel-level system error is immediately followed by `phx_close`; the channel is closed. Client libraries should expose a way for users to subscribe to `system` events since there is no automatic handling. + +| Message contains | Cause | Recovery | +| ------------------------------------------------- | -------------------------- | ----------------------------- | +| `Too many messages per second` | Broadcast/event rate limit | Throttle sends before rejoin | +| `Too many presence messages per second` | Tenant presence rate limit | Reduce presence frequency | +| `Client presence rate limit exceeded` | Per-client presence window | Longer cooldown before rejoin | +| `Track message size exceeded` | Presence payload too large | Shrink payload | +| `Token has expired` | JWT expired mid-session | Refresh token, rejoin | +| `Fields \`role\` and \`exp\` are required in JWT` | Claims missing | Fix token issuance | +| `Server requested disconnect` | Operational disconnect | Reconnect after delay | + +### Postgres Changes subscription errors + +`extension: "postgres_changes"`. These do **not** close the channel — broadcast and presence continue. `status: "ok"` with `message: "Subscribed to PostgreSQL"` confirms the subscription is live. + +| Scenario | Server retries? | Client action | +| ------------------------------------------------------ | ----------------- | -------------------------------------------------------------------------- | +| Invalid filter operator | No | Fix params and rejoin | +| Missing `schema`/`table` params | No | Fix params and rejoin | +| Subscription insert failed (table/publication missing) | Yes, every 5–10 s | Surface as degraded state; wait or check Realtime is enabled for the table | +| Database error during subscription | Yes, every 5–10 s | Surface as degraded state | +| `"Too many database timeouts"` | No | Reduce subscription load; retry later | + +Supported filter operators: `eq`, `neq`, `lt`, `lte`, `gt`, `gte`, `in`. + +The `ids` array on incoming `postgres_changes` payloads must match the subscription IDs returned in the `phx_join` reply. A mismatch means inconsistent server/client state — tear down and rejoin. + +### Broadcast errors + +Broadcast errors only affect **private channels**. When `config.broadcast.ack` is `false` (the default), all push failures — including size violations and RLS write denials — are silently dropped. RLS denials are always silent regardless of `ack`. + +When `ack` is `true`, the server replies on error with `response.error` (an atom string), not `response.reason`: + +```json +{ "status": "error", "response": { "error": "payload_size_exceeded" } } +``` + +Note that the JS client (`send()`) resolves to just the string `'error'` and does not expose the specific `error` atom to callers. + +### Presence errors + +Push replies surface payload-shape errors with `reason: "Presence track payload must be a map"`. Other push-level failures (RLS write denied, unknown event type, internal errors) return `status: "error"` with no reason field. + +Presence rate-limit and size violations arrive as channel-level system errors (see above) and close the channel. + +### Access token refresh + +Refresh the JWT in-band on private channels without rejoining using the `access_token` event: + +```json +["10", "1", "realtime:my-channel", "access_token", { "access_token": "" }] +``` + +There is no reply on success. On failure, the server emits a `system` error and closes the channel. Tokens with the `sb_*` prefix are silently ignored by the server. + +### Reconnection + +`phx_error` (unexpected server-side channel process termination, empty payload) should trigger a rejoin with exponential backoff. The JS client uses `[1000, 2000, 5000, 10000]` ms (capped at 10 s) configurable via `reconnectAfterMs`. + +`phx_close` following a rate-limit system error requires throttling before rejoin. Following a token system error, refresh the token first. A `phx_close` with no preceding system error is a clean close — only rejoin if it was unexpected. See [Limits](/docs/guides/realtime/limits) for the per-tenant thresholds that trigger rate-limit errors. diff --git a/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx b/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx index 5fc2646c4698f..54dd57f3450a9 100644 --- a/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx @@ -7,17 +7,19 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { useTableFilter } from '@/components/grid/hooks/useTableFilter' import type { SupaRow } from '@/components/grid/types' import { useDatabaseColumnDeleteMutation } from '@/data/database-columns/database-column-delete-mutation' -import { TableLike } from '@/data/table-editor/table-editor-types' +import { useMaterializedViewDeleteMutation } from '@/data/materialized-views/materialized-view-delete-mutation' +import { Entity } from '@/data/table-editor/table-editor-types' import { useTableRowDeleteAllMutation } from '@/data/table-rows/table-row-delete-all-mutation' import { useTableRowDeleteMutation } from '@/data/table-rows/table-row-delete-mutation' import { useTableRowTruncateMutation } from '@/data/table-rows/table-row-truncate-mutation' import { useTableDeleteMutation } from '@/data/tables/table-delete-mutation' +import { useViewDeleteMutation } from '@/data/views/view-delete-mutation' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useGetImpersonatedRoleState } from '@/state/role-impersonation-state' import { useTableEditorStateSnapshot } from '@/state/table-editor' export type DeleteConfirmationDialogsProps = { - selectedTable?: TableLike + selectedTable?: Entity onTableDeleted?: () => void } @@ -69,6 +71,32 @@ const DeleteConfirmationDialogs = ({ }, }) + const { mutate: deleteView } = useViewDeleteMutation({ + onSuccess: async () => { + toast.success(`Successfully deleted view "${selectedTable?.name}"`) + onTableDeleted?.() + }, + onError: (error) => { + toast.error(`Failed to delete ${selectedTable?.name}: ${error.message}`) + }, + onSettled: () => { + snap.closeConfirmationDialog() + }, + }) + + const { mutate: deleteMaterializedView } = useMaterializedViewDeleteMutation({ + onSuccess: async () => { + toast.success(`Successfully deleted materialized view "${selectedTable?.name}"`) + onTableDeleted?.() + }, + onError: (error) => { + toast.error(`Failed to delete ${selectedTable?.name}: ${error.message}`) + }, + onSettled: () => { + snap.closeConfirmationDialog() + }, + }) + const { mutate: deleteRows, isPending: isDeletingRows } = useTableRowDeleteMutation({ onSuccess: () => { if (snap.confirmationDialog?.type === 'row') { @@ -121,7 +149,10 @@ const DeleteConfirmationDialogs = ({ : 0 const isDeleteWithCascade = - snap.confirmationDialog?.type === 'column' || snap.confirmationDialog?.type === 'table' + snap.confirmationDialog?.type === 'column' || + snap.confirmationDialog?.type === 'table' || + snap.confirmationDialog?.type === 'view' || + snap.confirmationDialog?.type === 'materialized-view' ? snap.confirmationDialog.isDeleteWithCascade : false @@ -156,6 +187,34 @@ const DeleteConfirmationDialogs = ({ }) } + const onConfirmDeleteView = async () => { + if (snap.confirmationDialog?.type !== 'view') return + if (!project || !selectedTable) return + + deleteView({ + projectRef: project.ref, + connectionString: project.connectionString, + id: selectedTable.id, + name: selectedTable.name, + schema: selectedTable.schema, + cascade: isDeleteWithCascade, + }) + } + + const onConfirmDeleteMaterializedView = async () => { + if (snap.confirmationDialog?.type !== 'materialized-view') return + if (!project || !selectedTable) return + + deleteMaterializedView({ + projectRef: project.ref, + connectionString: project.connectionString, + id: selectedTable.id, + name: selectedTable.name, + schema: selectedTable.schema, + cascade: isDeleteWithCascade, + }) + } + const getImpersonatedRoleState = useGetImpersonatedRoleState() const onConfirmDeleteRow = async () => { @@ -320,6 +379,26 @@ const DeleteConfirmationDialogs = ({ + snap.toggleConfirmationIsWithCascade(!isDeleteWithCascade)} + onCancel={() => snap.closeConfirmationDialog()} + onConfirm={onConfirmDeleteView} + /> + + snap.toggleConfirmationIsWithCascade(!isDeleteWithCascade)} + onCancel={() => snap.closeConfirmationDialog()} + onConfirm={onConfirmDeleteMaterializedView} + /> + void + onCancel: () => void + onConfirm: () => void +} + +const DropEntityConfirmationModal = ({ + visible, + entityLabel, + entityName, + isDeleteWithCascade, + onToggleCascade, + onCancel, + onConfirm, +}: DropEntityConfirmationModalProps) => { + const checkboxId = `checkbox-cascade-${entityLabel.replace(/\s+/g, '-')}` + return ( + {`Confirm deletion of ${entityLabel} "${entityName ?? ''}"`} + } + confirmLabel="Delete" + confirmLabelLoading="Deleting" + onCancel={onCancel} + onConfirm={onConfirm} + > +
+

+ Are you sure you want to delete this {entityLabel}? This action cannot be undone. +

+
+ +
+ +

+ Deletes the {entityLabel} and its dependent objects +

+
+
+ {isDeleteWithCascade && ( + + + Warning: Dropping with cascade may result in unintended consequences + + + All dependent objects will be removed, as will any objects that depend on them, + recursively. + + + + + + )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index c116e66693b07..52851b70db1b5 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -193,7 +193,7 @@ export const TableGridEditor = ({ diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index 8308876173c08..0cff02de1245d 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -29,6 +29,7 @@ import { getEntityLintDetails } from '@/components/interfaces/TableGridEditor/Ta import { EntityTypeIcon } from '@/components/ui/EntityTypeIcon' import { InlineLink } from '@/components/ui/InlineLink' import { getTableDefinition } from '@/data/database/table-definition-query' +import { getViewDefinition } from '@/data/database/view-definition-query' import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' import { Entity } from '@/data/entity-types/entity-types-infinite-query' import { useProjectLintsQuery } from '@/data/lint/lint-query' @@ -243,7 +244,7 @@ export const EntityListItem = ({ onClick={(e) => e.preventDefault()} /> - + - + Copy name @@ -279,16 +280,62 @@ export const EntityListItem = ({ await copyToClipboard(formattedSchema, () => { toast.success('Table schema copied to clipboard', { id: toastId }) }) - } catch (err: any) { - toast.error('Failed to copy schema: ' + (err.message || err), { id: toastId }) + } catch (err: unknown) { + if (err instanceof Error) { + toast.error('Failed to copy schema: ' + (err.message || err), { + id: toastId, + }) + } } }} > - + Copy table schema )} + {(entity.type === ENTITY_TYPE.VIEW || + entity.type === ENTITY_TYPE.MATERIALIZED_VIEW) && ( + { + e.stopPropagation() + const label = + entity.type === ENTITY_TYPE.MATERIALIZED_VIEW ? 'materialized view' : 'view' + const toastId = toast.loading(`Getting ${label} definition...`) + + const formattedDefinition = getViewDefinition({ + id: entity.id, + projectRef: project?.ref, + connectionString: project?.connectionString, + includeCreateStatement: true, + }).then((definition) => { + if (!definition) { + throw new Error(`Failed to get ${label} definition`) + } + return formatSql(definition) + }) + + try { + await copyToClipboard(formattedDefinition, () => { + toast.success( + `${label[0].toUpperCase() + label.slice(1)} definition copied to clipboard`, + { id: toastId } + ) + }) + } catch (err: any) { + toast.error(`Failed to copy ${label} definition: ` + (err.message || err), { + id: toastId, + }) + } + }} + > + + Copy definition + + )} + {entity.type === ENTITY_TYPE.TABLE && ( <> @@ -301,7 +348,7 @@ export const EntityListItem = ({ snap.onEditTable() }} > - + Edit table - + Duplicate table @@ -320,14 +367,14 @@ export const EntityListItem = ({ key="view-policies" href={`/project/${projectRef}/auth/policies?schema=${encodeURIComponent(selectedSchema ?? '')}&search=${encodeURIComponent(String(entity.id))}`} > - + View policies - + Export data @@ -373,11 +420,107 @@ export const EntityListItem = ({ snap.onDeleteTable() }} > - + Delete table )} + + {entity.type === ENTITY_TYPE.VIEW && ( + <> + + + + + + Export data + + + { + e.stopPropagation() + exportCsv() + }} + > + Export view as CSV + + { + e.stopPropagation() + exportSql() + }} + > + Export view as SQL + + + + + + { + e.stopPropagation() + snap.onDeleteView() + }} + > + + Delete view + + + )} + + {entity.type === ENTITY_TYPE.MATERIALIZED_VIEW && ( + <> + + + + + + Export data + + + { + e.stopPropagation() + exportCsv() + }} + > + Export view as CSV + + { + e.stopPropagation() + exportSql() + }} + > + Export view as SQL + + + + + + { + e.stopPropagation() + snap.onDeleteMaterializedView() + }} + > + + Delete view + + + )} )} diff --git a/apps/studio/data/materialized-views/materialized-view-delete-mutation.ts b/apps/studio/data/materialized-views/materialized-view-delete-mutation.ts new file mode 100644 index 0000000000000..ec53bc2831408 --- /dev/null +++ b/apps/studio/data/materialized-views/materialized-view-delete-mutation.ts @@ -0,0 +1,79 @@ +import { ident, safeSql } from '@supabase/pg-meta/src/pg-format' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { materializedViewKeys } from './keys' +import { entityTypeKeys } from '@/data/entity-types/keys' +import { executeSql } from '@/data/sql/execute-sql-query' +import { tableEditorKeys } from '@/data/table-editor/keys' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type MaterializedViewDeleteVariables = { + projectRef: string + connectionString?: string | null + id: number + name: string + schema: string + cascade?: boolean +} + +export async function deleteMaterializedView({ + projectRef, + connectionString, + id, + name, + schema, + cascade = false, +}: MaterializedViewDeleteVariables) { + const sql = safeSql`DROP MATERIALIZED VIEW ${ident(schema)}.${ident(name)}${cascade ? safeSql` CASCADE` : safeSql``};` + + const { result } = await executeSql({ + projectRef, + connectionString, + sql, + queryKey: ['materialized-view', 'delete', id], + }) + + return result +} + +type MaterializedViewDeleteData = Awaited> + +export const useMaterializedViewDeleteMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions< + MaterializedViewDeleteData, + ResponseError, + MaterializedViewDeleteVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => deleteMaterializedView(vars), + async onSuccess(data, variables, context) { + const { id, projectRef, schema } = variables + await Promise.all([ + queryClient.invalidateQueries({ queryKey: tableEditorKeys.tableEditor(projectRef, id) }), + queryClient.invalidateQueries({ + queryKey: materializedViewKeys.listBySchema(projectRef, schema), + }), + queryClient.invalidateQueries({ queryKey: entityTypeKeys.list(projectRef) }), + ]) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to delete materialized view: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/views/view-delete-mutation.ts b/apps/studio/data/views/view-delete-mutation.ts new file mode 100644 index 0000000000000..94a41687f9717 --- /dev/null +++ b/apps/studio/data/views/view-delete-mutation.ts @@ -0,0 +1,73 @@ +import { ident, safeSql } from '@supabase/pg-meta/src/pg-format' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { viewKeys } from './keys' +import { entityTypeKeys } from '@/data/entity-types/keys' +import { executeSql } from '@/data/sql/execute-sql-query' +import { tableEditorKeys } from '@/data/table-editor/keys' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type ViewDeleteVariables = { + projectRef: string + connectionString?: string | null + id: number + name: string + schema: string + cascade?: boolean +} + +export async function deleteView({ + projectRef, + connectionString, + id, + name, + schema, + cascade = false, +}: ViewDeleteVariables) { + const sql = safeSql`DROP VIEW ${ident(schema)}.${ident(name)}${cascade ? safeSql` CASCADE` : safeSql``};` + + const { result } = await executeSql({ + projectRef, + connectionString, + sql, + queryKey: ['view', 'delete', id], + }) + + return result +} + +type ViewDeleteData = Awaited> + +export const useViewDeleteMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => deleteView(vars), + async onSuccess(data, variables, context) { + const { id, projectRef, schema } = variables + await Promise.all([ + queryClient.invalidateQueries({ queryKey: tableEditorKeys.tableEditor(projectRef, id) }), + queryClient.invalidateQueries({ queryKey: viewKeys.listBySchema(projectRef, schema) }), + queryClient.invalidateQueries({ queryKey: entityTypeKeys.list(projectRef) }), + ]) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to delete view: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/state/table-editor.tsx b/apps/studio/state/table-editor.tsx index 018059fdfc8a9..59f6372cf0fea 100644 --- a/apps/studio/state/table-editor.tsx +++ b/apps/studio/state/table-editor.tsx @@ -47,6 +47,8 @@ export type SidePanel = export type ConfirmationDialog = | { type: 'table'; isDeleteWithCascade: boolean } + | { type: 'view'; isDeleteWithCascade: boolean } + | { type: 'materialized-view'; isDeleteWithCascade: boolean } | { type: 'column'; column: SafePostgresColumn; isDeleteWithCascade: boolean } // [Joshen] Just FYI callback, numRows, allRowsSelected is a temp workaround so that // DeleteConfirmationDialog can trigger dispatch methods after the successful deletion of rows. @@ -135,6 +137,18 @@ export const createTableEditorState = () => { confirmationDialog: { type: 'table', isDeleteWithCascade: false }, } }, + onDeleteView: () => { + state.ui = { + open: 'confirmation-dialog', + confirmationDialog: { type: 'view', isDeleteWithCascade: false }, + } + }, + onDeleteMaterializedView: () => { + state.ui = { + open: 'confirmation-dialog', + confirmationDialog: { type: 'materialized-view', isDeleteWithCascade: false }, + } + }, /* Columns */ onAddColumn: () => { @@ -225,7 +239,9 @@ export const createTableEditorState = () => { if ( state.ui.open === 'confirmation-dialog' && (state.ui.confirmationDialog.type === 'column' || - state.ui.confirmationDialog.type === 'table') + state.ui.confirmationDialog.type === 'table' || + state.ui.confirmationDialog.type === 'view' || + state.ui.confirmationDialog.type === 'materialized-view') ) { state.ui.confirmationDialog.isDeleteWithCascade = overrideIsDeleteWithCascade ?? !state.ui.confirmationDialog.isDeleteWithCascade diff --git a/e2e/studio/features/table-editor-views.spec.ts b/e2e/studio/features/table-editor-views.spec.ts new file mode 100644 index 0000000000000..27e420ac467e1 --- /dev/null +++ b/e2e/studio/features/table-editor-views.spec.ts @@ -0,0 +1,293 @@ +import crypto from 'node:crypto' +import { expect, Page } from '@playwright/test' + +import { expectClipboardValue } from '../utils/clipboard.js' +import { + createMaterializedView, + createTable, + createView, + dropMaterializedView, + dropTable, + dropView, +} from '../utils/db/queries.js' +import { test } from '../utils/test.js' +import { toUrl } from '../utils/to-url.js' +import { createApiResponseWaiter, waitForApiResponse } from '../utils/wait-for-response.js' + +/** + * Opens the entity context menu in the table editor sidebar. + * The entity must be the currently-selected one (canEdit = isActive && !isLocked). + */ +const openEntityContextMenu = async (page: Page, entityName: string) => { + const entityButton = page.getByRole('button', { name: `View ${entityName}`, exact: true }) + await entityButton.click() + await entityButton.hover() + const menuButton = entityButton.locator('button[aria-haspopup="menu"]') + await expect(menuButton).toBeVisible({ timeout: 30000 }) + await menuButton.click() +} + +const goToTableEditor = async (page: Page, ref: string) => { + const tableLoadWait = createApiResponseWaiter( + page, + 'pg-meta', + ref, + 'query?key=entity-types-public-' + ) + await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) + await tableLoadWait +} + +const uniqueSuffix = () => crypto.randomBytes(4).toString('hex') + +/** + * Each test owns its own base table + view so tests can run in parallel without + * stomping on each other's DB state. Use with `await using fixture = ...` so + * cleanup runs whether the test passes or fails. + */ +const setupViewFixture = async ( + rows: Array> = [{ note: 'alpha' }, { note: 'beta' }] +) => { + const suffix = uniqueSuffix() + const baseTable = `pw_view_menu_base_${suffix}` + const viewName = `pw_view_menu_view_${suffix}` + await createTable(baseTable, 'note', rows) + await createView(viewName, `SELECT id, note FROM public.${baseTable}`) + return { + baseTable, + viewName, + async [Symbol.asyncDispose]() { + await dropView(viewName) + await dropTable(baseTable) + }, + } +} + +const setupMaterializedViewFixture = async ( + rows: Array> = [{ note: 'alpha' }, { note: 'beta' }] +) => { + const suffix = uniqueSuffix() + const baseTable = `pw_mv_menu_base_${suffix}` + const mvName = `pw_mv_menu_view_${suffix}` + await createTable(baseTable, 'note', rows) + await createMaterializedView(mvName, `SELECT id, note FROM public.${baseTable}`) + return { + baseTable, + mvName, + async [Symbol.asyncDispose]() { + await dropMaterializedView(mvName) + await dropTable(baseTable) + }, + } +} + +test.describe('table editor — view context menu', () => { + test('copy name copies the view name to clipboard', async ({ page, ref }) => { + await using fixture = await setupViewFixture() + await goToTableEditor(page, ref) + + await openEntityContextMenu(page, fixture.viewName) + await page.getByRole('menuitem', { name: 'Copy name' }).click() + await expect( + page.getByRole('menuitem', { name: 'Copy name' }), + 'menu should close after Copy name click' + ).not.toBeVisible() + + await expectClipboardValue({ page, value: fixture.viewName, exact: true }) + }) + + test('copy view definition copies CREATE VIEW statement to clipboard', async ({ page, ref }) => { + await using fixture = await setupViewFixture() + await goToTableEditor(page, ref) + + await openEntityContextMenu(page, fixture.viewName) + + const definitionWait = waitForApiResponse(page, 'pg-meta', ref, 'query?key=view-definition-') + await page.getByRole('menuitem', { name: 'Copy definition' }).click() + await definitionWait + + await expect( + page.getByText('View definition copied to clipboard'), + 'success toast should appear after copy' + ).toBeVisible({ timeout: 15000 }) + + const clipboardText: string = await page.evaluate(() => navigator.clipboard.readText()) + expect(clipboardText.toLowerCase()).toContain(`create view`) + expect(clipboardText.toLowerCase()).toContain(fixture.viewName.toLowerCase()) + }) + + test('export view as CSV shows confirmation reason and downloads', async ({ page, ref }) => { + await using fixture = await setupViewFixture() + await goToTableEditor(page, ref) + + await openEntityContextMenu(page, fixture.viewName) + const exportItem = page.getByRole('menuitem', { name: 'Export data' }) + await expect(exportItem).toBeVisible() + await exportItem.hover() + await expect(exportItem).toHaveAttribute('data-state', /open/) + await page.getByRole('menuitem', { name: 'Export view as CSV' }).click() + + // Confirmation modal appears with reason text — guards the shared-component fix. + await expect( + page.getByText('Confirm to export data'), + 'export confirmation dialog should appear' + ).toBeVisible({ timeout: 15000 }) + await expect( + page.getByText(/Exporting a view may cause consistency issues/i), + 'confirmation reason text should be visible inside the modal' + ).toBeVisible() + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'Submit' }).click(), + ]) + expect(download.suggestedFilename()).toContain('.csv') + }) + + test('export view as SQL shows confirmation and downloads', async ({ page, ref }) => { + await using fixture = await setupViewFixture() + await goToTableEditor(page, ref) + + await openEntityContextMenu(page, fixture.viewName) + const exportItem = page.getByRole('menuitem', { name: 'Export data' }) + await expect(exportItem).toBeVisible() + await exportItem.hover() + await expect(exportItem).toHaveAttribute('data-state', /open/) + await page.getByRole('menuitem', { name: 'Export view as SQL' }).click() + + await expect( + page.getByText('Confirm to export data'), + 'export confirmation dialog should appear' + ).toBeVisible({ timeout: 15000 }) + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'Submit' }).click(), + ]) + expect(download.suggestedFilename()).toContain('.sql') + }) + + test('delete view runs DROP VIEW and removes it from the sidebar', async ({ page, ref }) => { + await using fixture = await setupViewFixture([{ note: 'alpha' }]) + await goToTableEditor(page, ref) + + await openEntityContextMenu(page, fixture.viewName) + await page.getByRole('menuitem', { name: 'Delete view' }).click() + + await expect( + page.getByRole('heading', { name: `Confirm deletion of view "${fixture.viewName}"` }), + 'confirm dialog title should include the view name' + ).toBeVisible({ timeout: 15000 }) + + const deletePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=view-delete-', { + method: 'POST', + }) + const entityTypesPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=entity-types-') + await page.getByRole('button', { name: 'Delete', exact: true }).click() + await Promise.all([deletePromise, entityTypesPromise]) + + await expect + .poll( + async () => + await page.getByRole('button', { name: `View ${fixture.viewName}`, exact: true }).count(), + { message: 'view should be removed from the sidebar after delete' } + ) + .toBe(0) + }) +}) + +test.describe('table editor — materialized view context menu', () => { + test('copy name copies the materialized view name to clipboard', async ({ page, ref }) => { + await using fixture = await setupMaterializedViewFixture() + await goToTableEditor(page, ref) + + await openEntityContextMenu(page, fixture.mvName) + await page.getByRole('menuitem', { name: 'Copy name' }).click() + await expect(page.getByRole('menuitem', { name: 'Copy name' })).not.toBeVisible() + + await expectClipboardValue({ page, value: fixture.mvName, exact: true }) + }) + + test('copy materialized view definition copies CREATE statement to clipboard', async ({ + page, + ref, + }) => { + await using fixture = await setupMaterializedViewFixture() + await goToTableEditor(page, ref) + + await openEntityContextMenu(page, fixture.mvName) + + const definitionWait = waitForApiResponse(page, 'pg-meta', ref, 'query?key=view-definition-') + await page.getByRole('menuitem', { name: 'Copy definition' }).click() + await definitionWait + + await expect(page.getByText('Materialized view definition copied to clipboard')).toBeVisible({ + timeout: 15000, + }) + + const clipboardText: string = await page.evaluate(() => navigator.clipboard.readText()) + expect(clipboardText.toLowerCase()).toContain('create materialized view') + expect(clipboardText.toLowerCase()).toContain(fixture.mvName.toLowerCase()) + }) + + test('export materialized view as CSV shows confirmation and downloads', async ({ + page, + ref, + }) => { + await using fixture = await setupMaterializedViewFixture() + await goToTableEditor(page, ref) + + await openEntityContextMenu(page, fixture.mvName) + const exportItem = page.getByRole('menuitem', { name: 'Export data' }) + await expect(exportItem).toBeVisible() + await exportItem.hover() + await expect(exportItem).toHaveAttribute('data-state', /open/) + await page.getByRole('menuitem', { name: 'Export view as CSV' }).click() + + await expect( + page.getByText(/Exporting a materialized view may cause performance issues/i), + 'materialized view-specific confirmation reason should appear' + ).toBeVisible({ timeout: 15000 }) + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'Submit' }).click(), + ]) + expect(download.suggestedFilename()).toContain('.csv') + }) + + test('delete materialized view runs DROP and removes it from the sidebar', async ({ + page, + ref, + }) => { + await using fixture = await setupMaterializedViewFixture() + await goToTableEditor(page, ref) + + await openEntityContextMenu(page, fixture.mvName) + await page.getByRole('menuitem', { name: 'Delete view' }).click() + + await expect( + page.getByRole('heading', { + name: `Confirm deletion of materialized view "${fixture.mvName}"`, + }) + ).toBeVisible({ timeout: 15000 }) + + const deletePromise = waitForApiResponse( + page, + 'pg-meta', + ref, + 'query?key=materialized-view-delete-', + { method: 'POST' } + ) + const entityTypesPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=entity-types-') + await page.getByRole('button', { name: 'Delete', exact: true }).click() + await Promise.all([deletePromise, entityTypesPromise]) + + await expect + .poll( + async () => + await page.getByRole('button', { name: `View ${fixture.mvName}`, exact: true }).count() + ) + .toBe(0) + }) +}) diff --git a/e2e/studio/utils/db/index.ts b/e2e/studio/utils/db/index.ts index c5979283622da..177c8bb5111bb 100644 --- a/e2e/studio/utils/db/index.ts +++ b/e2e/studio/utils/db/index.ts @@ -1,2 +1,10 @@ export { query } from './client.js' -export { createTable, dropTable, tableExists } from './queries.js' +export { + createMaterializedView, + createTable, + createView, + dropMaterializedView, + dropTable, + dropView, + tableExists, +} from './queries.js' diff --git a/e2e/studio/utils/db/queries.ts b/e2e/studio/utils/db/queries.ts index cd6b8034fde65..90fbe0fbb12c9 100644 --- a/e2e/studio/utils/db/queries.ts +++ b/e2e/studio/utils/db/queries.ts @@ -69,3 +69,41 @@ export async function createTableWithRLS( export async function dropTable(tableName: string) { await query(`DROP TABLE IF EXISTS ${tableName} CASCADE`) } + +/** + * Create a view in the public schema. Assumes the underlying table already exists. + * + * @param viewName - The view name to create + * @param selectSql - The SELECT statement that defines the view (without trailing semicolon) + */ +export async function createView(viewName: string, selectSql: string) { + await query(`CREATE OR REPLACE VIEW public.${viewName} AS ${selectSql}`) +} + +/** + * Drop a view if it exists. + * + * @param viewName - The view name to drop + */ +export async function dropView(viewName: string) { + await query(`DROP VIEW IF EXISTS public.${viewName} CASCADE`) +} + +/** + * Create a materialized view in the public schema. + * + * @param viewName - The materialized view name to create + * @param selectSql - The SELECT statement that defines the view (without trailing semicolon) + */ +export async function createMaterializedView(viewName: string, selectSql: string) { + await query(`CREATE MATERIALIZED VIEW IF NOT EXISTS public.${viewName} AS ${selectSql}`) +} + +/** + * Drop a materialized view if it exists. + * + * @param viewName - The materialized view name to drop + */ +export async function dropMaterializedView(viewName: string) { + await query(`DROP MATERIALIZED VIEW IF EXISTS public.${viewName} CASCADE`) +} diff --git a/packages/ui-patterns/src/LogsBarChart/index.tsx b/packages/ui-patterns/src/LogsBarChart/index.tsx index 6bd8f23612f3f..070186a6990ce 100644 --- a/packages/ui-patterns/src/LogsBarChart/index.tsx +++ b/packages/ui-patterns/src/LogsBarChart/index.tsx @@ -70,10 +70,7 @@ export const LogsBarChart = ({ data-testid="logs-bar-chart" className={cn('flex flex-col gap-y-3', isFullHeight ? 'h-full' : 'h-24')} > - + { @@ -97,7 +94,6 @@ export const LogsBarChart = ({ dataKey="timestamp" interval={data.length - 2} tick={false} - height={hideDateRange ? 1 : undefined} axisLine={{ stroke: CHART_COLORS.AXIS }} tickLine={{ stroke: CHART_COLORS.AXIS }} />