Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/labeler.yml
Original file line number Diff line number Diff line change
@@ -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/**'
18 changes: 14 additions & 4 deletions .github/workflows/label_prs.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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.',
})
6 changes: 6 additions & 0 deletions .github/workflows/validate-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
103 changes: 100 additions & 3 deletions apps/docs/content/guides/realtime/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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", {}]
Expand All @@ -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.

Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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 `"<ErrorCode>: <human message>"`. 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 `<Code>: <message>` 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": "<new-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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -320,6 +379,26 @@ const DeleteConfirmationDialogs = ({
</div>
</ConfirmationModal>

<DropEntityConfirmationModal
visible={snap.confirmationDialog?.type === 'view'}
entityLabel="view"
entityName={selectedTable?.name}
isDeleteWithCascade={isDeleteWithCascade}
onToggleCascade={() => snap.toggleConfirmationIsWithCascade(!isDeleteWithCascade)}
onCancel={() => snap.closeConfirmationDialog()}
onConfirm={onConfirmDeleteView}
/>

<DropEntityConfirmationModal
visible={snap.confirmationDialog?.type === 'materialized-view'}
entityLabel="materialized view"
entityName={selectedTable?.name}
isDeleteWithCascade={isDeleteWithCascade}
onToggleCascade={() => snap.toggleConfirmationIsWithCascade(!isDeleteWithCascade)}
onCancel={() => snap.closeConfirmationDialog()}
onConfirm={onConfirmDeleteMaterializedView}
/>

<ConfirmationModal
variant={'destructive'}
size="small"
Expand Down Expand Up @@ -352,3 +431,85 @@ const DeleteConfirmationDialogs = ({
}

export default DeleteConfirmationDialogs

type DropEntityConfirmationModalProps = {
visible: boolean
entityLabel: 'view' | 'materialized view'
entityName?: string
isDeleteWithCascade: boolean
onToggleCascade: () => void
onCancel: () => void
onConfirm: () => void
}

const DropEntityConfirmationModal = ({
visible,
entityLabel,
entityName,
isDeleteWithCascade,
onToggleCascade,
onCancel,
onConfirm,
}: DropEntityConfirmationModalProps) => {
const checkboxId = `checkbox-cascade-${entityLabel.replace(/\s+/g, '-')}`
return (
<ConfirmationModal
variant="destructive"
size="small"
visible={visible}
title={
<span className="wrap-break-word">{`Confirm deletion of ${entityLabel} "${entityName ?? ''}"`}</span>
}
confirmLabel="Delete"
confirmLabelLoading="Deleting"
onCancel={onCancel}
onConfirm={onConfirm}
>
<div className="space-y-4">
<p className="text-sm text-foreground-light">
Are you sure you want to delete this {entityLabel}? This action cannot be undone.
</p>
<div className="items-top flex space-x-2">
<Checkbox
id={checkboxId}
checked={isDeleteWithCascade}
onCheckedChange={onToggleCascade}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor={checkboxId}
className="text-sm text-foreground-light leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Drop {entityLabel} with cascade?
</label>
<p className="text-sm text-foreground-muted">
Deletes the {entityLabel} and its dependent objects
</p>
</div>
</div>
{isDeleteWithCascade && (
<Alert variant="warning">
<AlertTitle>
Warning: Dropping with cascade may result in unintended consequences
</AlertTitle>
<AlertDescription>
All dependent objects will be removed, as will any objects that depend on them,
recursively.
</AlertDescription>
<AlertDescription className="mt-4">
<Button asChild size="tiny" type="default" icon={<ExternalLink />}>
<Link
href="https://www.postgresql.org/docs/current/ddl-depend.html"
target="_blank"
rel="noreferrer"
>
About dependency tracking
</Link>
</Button>
</AlertDescription>
</Alert>
)}
</div>
</ConfirmationModal>
)
}
Loading
Loading