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
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Link from 'next/link'
import { useState } from 'react'
import { toast } from 'sonner'

import { useFlag, useParams } from 'common'
import { useParams } from 'common'
import { RefreshButton } from 'components/grid/components/header/RefreshButton'
import { getEntityLintDetails } from 'components/interfaces/TableGridEditor/TableEntity.utils'
import { APIDocsButton } from 'components/ui/APIDocsButton'
Expand All @@ -22,13 +22,13 @@ import {
isView as isTableLikeView,
} from 'data/table-editor/table-editor-types'
import { useTableUpdateMutation } from 'data/tables/table-update-mutation'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { RealtimeButtonVariant, useRealtimeExperiment } from 'hooks/misc/useRealtimeExperiment'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useIsProtectedSchema } from 'hooks/useProtectedSchemas'
import { DOCS_URL } from 'lib/constants'
import { useTrack } from 'lib/telemetry/track'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import {
Expand All @@ -54,7 +54,7 @@ export interface GridHeaderActionsProps {
export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProps) => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const { data: org } = useSelectedOrganizationQuery()
const track = useTrack()

const [showWarning, setShowWarning] = useQueryState(
'showWarning',
Expand All @@ -69,7 +69,6 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp
const isView = isTableLikeView(table)
const isMaterializedView = isTableLikeMaterializedView(table)

const triggersInsteadOfRealtime = useFlag<boolean>('triggersInsteadOfRealtime')
const { realtimeAll: realtimeEnabled } = useIsFeatureEnabled(['realtime:all'])
const { isSchemaLocked } = useIsProtectedSchema({ schema: table.schema })

Expand Down Expand Up @@ -106,12 +105,24 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp
(publication) => publication.name === 'supabase_realtime'
)
const realtimeEnabledTables = realtimePublication?.tables ?? []
const isRealtimeEnabled = realtimeEnabledTables.some((t: any) => t.id === table?.id)
const isRealtimeEnabled = realtimeEnabledTables.some((t) => t.id === table?.id)

const { activeVariant: activeRealtimeVariant } = useRealtimeExperiment({
projectInsertedAt: project?.inserted_at,
isTable,
isRealtimeEnabled,
})

const { mutate: updatePublications, isLoading: isTogglingRealtime } =
useDatabasePublicationUpdateMutation({
onSuccess: () => {
setShowEnableRealtime(false)

track(isRealtimeEnabled ? 'table_realtime_disabled' : 'table_realtime_enabled', {
method: 'ui',
schema_name: table.schema,
table_name: table.name,
})
},
onError: (error) => {
toast.error(`Failed to toggle realtime for ${table.name}: ${error.message}`)
Expand Down Expand Up @@ -162,33 +173,21 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp
table.schema
)

const { mutate: sendEvent } = useSendEventMutation()

const manageTriggersHref = `/project/${ref}/database/triggers?schema=${table.schema}`

const toggleRealtime = async () => {
if (!project) return console.error('Project is required')
if (!realtimePublication) return console.error('Unable to find realtime publication')
if (!project || !realtimePublication) return

const exists = realtimeEnabledTables.some((x: any) => x.id == table.id)
const exists = realtimeEnabledTables.some((x) => x.id === table.id)
const tables = !exists
? [`${table.schema}.${table.name}`].concat(
realtimeEnabledTables.map((t: any) => `${t.schema}.${t.name}`)
realtimeEnabledTables.map((t) => `${t.schema}.${t.name}`)
)
: realtimeEnabledTables
.filter((x: any) => x.id != table.id)
.map((x: any) => `${x.schema}.${x.name}`)

sendEvent({
action: 'realtime_toggle_table_clicked',
properties: {
newState: exists ? 'disabled' : 'enabled',
origin: 'tableGridHeader',
},
groups: {
project: project?.ref ?? 'Unknown',
organization: org?.slug ?? 'Unknown',
},
: realtimeEnabledTables.filter((x) => x.id !== table.id).map((x) => `${x.schema}.${x.name}`)

track('realtime_toggle_table_clicked', {
newState: exists ? 'disabled' : 'enabled',
origin: 'tableGridHeader',
})

updatePublications({
Expand Down Expand Up @@ -217,17 +216,10 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp
payload: payload,
})

sendEvent({
action: 'table_rls_enabled',
properties: {
method: 'table_editor',
schema_name: table.schema,
table_name: table.name,
},
groups: {
project: projectRef,
...(org?.slug && { organization: org.slug }),
},
track('table_rls_enabled', {
method: 'table_editor',
schema_name: table.schema,
table_name: table.name,
})
}

Expand Down Expand Up @@ -344,7 +336,7 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp
)
) : null}

{isTable && triggersInsteadOfRealtime ? (
{isTable && activeRealtimeVariant === RealtimeButtonVariant.TRIGGERS ? (
<Button
asChild
type={'default'}
Expand All @@ -367,6 +359,7 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp
</Link>
</Button>
) : (
activeRealtimeVariant !== RealtimeButtonVariant.HIDE_BUTTON &&
realtimeEnabled && (
<ButtonTooltip
type="default"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose'
import { useUrlState } from 'hooks/ui/useUrlState'
import { useTrack } from 'lib/telemetry/track'
import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
Expand Down Expand Up @@ -132,6 +133,7 @@ export const SidePanelEditor = ({
const queryClient = useQueryClient()
const { data: project } = useSelectedProjectQuery()
const { data: org } = useSelectedOrganizationQuery()
const track = useTrack()

const [isEdited, setIsEdited] = useState<boolean>(false)

Expand Down Expand Up @@ -397,6 +399,12 @@ export const SidePanelEditor = ({
publish_delete: true,
tables: realtimeTables,
})

track(enabled ? 'table_realtime_enabled' : 'table_realtime_disabled', {
method: 'ui',
schema_name: table.schema,
table_name: table.name,
})
return
}
if (realtimePublication.tables === null) {
Expand Down Expand Up @@ -425,6 +433,12 @@ export const SidePanelEditor = ({
connectionString: project.connectionString,
tables: realtimeTables,
})

track(enabled ? 'table_realtime_enabled' : 'table_realtime_disabled', {
method: 'ui',
schema_name: table.schema,
table_name: table.name,
})
return
}
const isAlreadyEnabled = realtimePublication.tables.some((x) => x.id == table.id)
Expand All @@ -447,6 +461,12 @@ export const SidePanelEditor = ({
connectionString: project.connectionString,
tables: realtimeTables,
})

track(enabled ? 'table_realtime_enabled' : 'table_realtime_disabled', {
method: 'ui',
schema_name: table.schema,
table_name: table.name,
})
} catch (error: any) {
toast.error(`Failed to update realtime for ${table.name}: ${error.message}`)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import { useDatabasePublicationsQuery } from 'data/database-publications/databas
import { CONSTRAINT_TYPE, useTableConstraintsQuery } from 'data/database/constraints-query'
import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query'
import { useEnumeratedTypesQuery } from 'data/enumerated-types/enumerated-types-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useTrack } from 'lib/telemetry/track'
import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useRealtimeExperiment, RealtimeButtonVariant } from 'hooks/misc/useRealtimeExperiment'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useUrlState } from 'hooks/ui/useUrlState'
import { useProtectedSchemas } from 'hooks/useProtectedSchemas'
Expand Down Expand Up @@ -72,14 +72,12 @@ export const TableEditor = ({
const snap = useTableEditorStateSnapshot()

const { data: project } = useSelectedProjectQuery()
const { data: org } = useSelectedOrganizationQuery()
const { selectedSchema } = useQuerySchemaState()

const isNewRecord = isUndefined(table)
const { realtimeAll: realtimeEnabled } = useIsFeatureEnabled(['realtime:all'])
const { docsRowLevelSecurityGuidePath } = useCustomContent(['docs:row_level_security_guide_path'])
const { mutate: sendEvent } = useSendEventMutation()
const track = useTrack()

const isNewRecord = isUndefined(table)
const { docsRowLevelSecurityGuidePath } = useCustomContent(['docs:row_level_security_guide_path'])

const [params, setParams] = useUrlState()
useEffect(() => {
Expand Down Expand Up @@ -110,6 +108,12 @@ export const TableEditor = ({
? false
: realtimeEnabledTables.some((t) => t.id === table?.id)

const { activeVariant: activeRealtimeVariant } = useRealtimeExperiment({
projectInsertedAt: project?.inserted_at,
isTable: !isNewRecord,
isRealtimeEnabled,
})

const [errors, setErrors] = useState<PlainObject>({})
const [tableFields, setTableFields] = useState<TableField>()
const [fkRelations, setFkRelations] = useState<ForeignKey[]>([])
Expand Down Expand Up @@ -396,23 +400,16 @@ export const TableEditor = ({
</Admonition>
)}

{realtimeEnabled && (
{activeRealtimeVariant !== RealtimeButtonVariant.HIDE_BUTTON && realtimeEnabled && (
<Checkbox
id="enable-realtime"
label="Enable Realtime"
description="Broadcast changes on this table to authorized subscribers"
checked={tableFields.isRealtimeEnabled}
onChange={() => {
sendEvent({
action: 'realtime_toggle_table_clicked',
properties: {
newState: tableFields.isRealtimeEnabled ? 'disabled' : 'enabled',
origin: 'tableSidePanel',
},
groups: {
project: project?.ref ?? 'Unknown',
organization: org?.slug ?? 'Unknown',
},
track('realtime_toggle_table_clicked', {
newState: tableFields.isRealtimeEnabled ? 'disabled' : 'enabled',
origin: 'tableSidePanel',
})
onUpdateField({ isRealtimeEnabled: !tableFields.isRealtimeEnabled })
}}
Expand Down
112 changes: 112 additions & 0 deletions apps/studio/hooks/misc/useRealtimeExperiment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import { useEffect, useMemo, useRef } from 'react'

import { usePHFlag } from 'hooks/ui/useFlag'
import { IS_PLATFORM } from 'lib/constants'
import { useTrack } from 'lib/telemetry/track'

dayjs.extend(utc)

/**
* Days after project creation to be considered "new" for experiment targeting
*/
export const NEW_PROJECT_THRESHOLD_DAYS = 7

export enum RealtimeButtonVariant {
CONTROL = 'control',
HIDE_BUTTON = 'hide-button',
TRIGGERS = 'triggers',
}

interface UseRealtimeExperimentOptions {
/**
* Project creation timestamp
*/
projectInsertedAt?: string
/**
* Whether the current context is a table (not a view/foreign table)
*/
isTable?: boolean
/**
* Whether realtime is currently enabled for the table
*/
isRealtimeEnabled?: boolean
}

interface UseRealtimeExperimentResult {
/**
* The active variant for this user/project, or null if not in experiment
*/
activeVariant: RealtimeButtonVariant | null
/**
* Whether this project is considered "new" for experiment targeting
*/
isNewProject: boolean
}

/**
* Hook to manage the realtime button A/B experiment logic.
* Handles variant determination, exposure tracking, and date validation.
*
* @param options Configuration for experiment targeting
* @returns Experiment state including active variant and project age
*/
export function useRealtimeExperiment({
projectInsertedAt,
isTable = false,
isRealtimeEnabled = false,
}: UseRealtimeExperimentOptions): UseRealtimeExperimentResult {
const track = useTrack()
const realtimeButtonVariant = usePHFlag<RealtimeButtonVariant>('realtimeButtonVariant')
const hasTrackedExposure = useRef(false)

const isNewProject = useMemo(() => {
if (!projectInsertedAt) return false

const insertedDate = dayjs.utc(projectInsertedAt)
if (!insertedDate.isValid()) {
return false
}

return dayjs.utc().diff(insertedDate, 'day') < NEW_PROJECT_THRESHOLD_DAYS
}, [projectInsertedAt])

const activeVariant = useMemo(() => {
if (!IS_PLATFORM) return null
if (!isTable || !isNewProject) return null
if (!realtimeButtonVariant || realtimeButtonVariant === RealtimeButtonVariant.CONTROL) {
return null
}
return realtimeButtonVariant
}, [isTable, isNewProject, realtimeButtonVariant])

useEffect(() => {
if (!IS_PLATFORM) return
if (hasTrackedExposure.current) return
if (!isTable || !isNewProject || !projectInsertedAt) return
if (!realtimeButtonVariant) return

hasTrackedExposure.current = true

try {
const insertedDate = dayjs.utc(projectInsertedAt)
if (!insertedDate.isValid()) return

const daysSinceCreation = dayjs.utc().diff(insertedDate, 'day')

track('realtime_experiment_exposed', {
variant: realtimeButtonVariant,
table_has_realtime_enabled: isRealtimeEnabled,
days_since_project_creation: daysSinceCreation,
})
} catch {
hasTrackedExposure.current = false
}
}, [isTable, isNewProject, realtimeButtonVariant, projectInsertedAt, isRealtimeEnabled, track])

return {
activeVariant,
isNewProject,
}
}
Loading
Loading