Skip to content

Commit 2c651dd

Browse files
authored
fix(experiment): make dataApiRevokeOnCreateDefault flag reads shape-agnostic (supabase#46289)
1 parent cc778ce commit 2c651dd

5 files changed

Lines changed: 147 additions & 18 deletions

File tree

apps/studio/hooks/misc/__tests__/useDataApiRevokeOnCreateDefault.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { renderHook } from '@testing-library/react'
22
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
33

44
import {
5+
isInDataApiRevokeTreatment,
56
useDataApiRevokeOnCreateDefaultEnabled,
67
useTrackDefaultPrivilegesExposure,
78
} from '../useDataApiRevokeOnCreateDefault'
@@ -25,6 +26,33 @@ vi.mock('@/lib/telemetry/track', () => ({
2526
useTrack: vi.fn(),
2627
}))
2728

29+
describe('isInDataApiRevokeTreatment', () => {
30+
it('returns true for boolean true (current rollout shape)', () => {
31+
expect(isInDataApiRevokeTreatment(true)).toBe(true)
32+
})
33+
34+
it("returns true for the 'test' variant (future multivariate shape)", () => {
35+
expect(isInDataApiRevokeTreatment('test')).toBe(true)
36+
})
37+
38+
it('returns false for boolean false', () => {
39+
expect(isInDataApiRevokeTreatment(false)).toBe(false)
40+
})
41+
42+
it("returns false for the 'control' variant", () => {
43+
expect(isInDataApiRevokeTreatment('control')).toBe(false)
44+
})
45+
46+
it('returns false for undefined (flag not resolved)', () => {
47+
expect(isInDataApiRevokeTreatment(undefined)).toBe(false)
48+
})
49+
50+
it('returns false for unrelated string values', () => {
51+
expect(isInDataApiRevokeTreatment('something-else')).toBe(false)
52+
expect(isInDataApiRevokeTreatment('')).toBe(false)
53+
})
54+
})
55+
2856
describe('useDataApiRevokeOnCreateDefaultEnabled', () => {
2957
afterEach(() => {
3058
vi.restoreAllMocks()
@@ -49,6 +77,18 @@ describe('useDataApiRevokeOnCreateDefaultEnabled', () => {
4977
expect(result.current).toBe(true)
5078
})
5179

80+
it("returns true when the PostHog flag is the 'test' variant string", () => {
81+
vi.mocked(usePHFlag).mockReturnValue('test')
82+
const { result } = renderHook(() => useDataApiRevokeOnCreateDefaultEnabled())
83+
expect(result.current).toBe(true)
84+
})
85+
86+
it("returns false when the PostHog flag is the 'control' variant string", () => {
87+
vi.mocked(usePHFlag).mockReturnValue('control')
88+
const { result } = renderHook(() => useDataApiRevokeOnCreateDefaultEnabled())
89+
expect(result.current).toBe(false)
90+
})
91+
5292
it('returns false in test env regardless of flag value', () => {
5393
vi.mocked(constants, { partial: true }).IS_TEST_ENV = true
5494
vi.mocked(usePHFlag).mockReturnValue(true)
@@ -252,4 +292,53 @@ describe('useTrackDefaultPrivilegesExposure', () => {
252292
undefined
253293
)
254294
})
295+
296+
// The next two tests cover the future multivariate flag shape (GROWTH-877).
297+
// Today the flag returns boolean true/false; post-migration it returns the
298+
// variant string. The convergence gate must derive the expected default from
299+
// the variant, not from `!flag` directly — `!'control'` is false (truthy
300+
// string negation), which would have set the wrong expected default and
301+
// either skipped or mis-fired the exposure for control-arm users.
302+
303+
it("fires for the 'test' variant with the correct convergence default (treatment)", () => {
304+
vi.mocked(usePHFlag).mockReturnValue('test')
305+
renderHook(() =>
306+
useTrackDefaultPrivilegesExposure({
307+
surface: 'main',
308+
dataApiDefaultPrivileges: false, // expected default for treatment
309+
hasUserModified: false,
310+
})
311+
)
312+
expect(track).toHaveBeenCalledTimes(1)
313+
expect(track).toHaveBeenCalledWith(
314+
'project_creation_default_privileges_exposed',
315+
{
316+
surface: 'main',
317+
dataApiDefaultPrivileges: false,
318+
dataApiRevokeOnCreateDefaultEnabled: 'test',
319+
},
320+
undefined
321+
)
322+
})
323+
324+
it("fires for the 'control' variant with the correct convergence default (legacy)", () => {
325+
vi.mocked(usePHFlag).mockReturnValue('control')
326+
renderHook(() =>
327+
useTrackDefaultPrivilegesExposure({
328+
surface: 'main',
329+
dataApiDefaultPrivileges: true, // expected default for non-treatment
330+
hasUserModified: false,
331+
})
332+
)
333+
expect(track).toHaveBeenCalledTimes(1)
334+
expect(track).toHaveBeenCalledWith(
335+
'project_creation_default_privileges_exposed',
336+
{
337+
surface: 'main',
338+
dataApiDefaultPrivileges: true,
339+
dataApiRevokeOnCreateDefaultEnabled: 'control',
340+
},
341+
undefined
342+
)
343+
})
255344
})

apps/studio/hooks/misc/useDataApiRevokeOnCreateDefault.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,34 @@ import { usePHFlag } from '../ui/useFlag'
44
import { IS_TEST_ENV } from '@/lib/constants'
55
import { useTrack } from '@/lib/telemetry/track'
66

7+
/**
8+
* Returns true iff the user is assigned to the treatment arm of the
9+
* dataApiRevokeOnCreateDefault experiment. Shape-agnostic across the current
10+
* boolean rollout config and a future multivariate experiment with named
11+
* variants. See GROWTH-877 for the migration plan.
12+
*
13+
* Accepts:
14+
* - `true` → treatment (current boolean shape)
15+
* - `'test'` → treatment (future multivariate shape)
16+
* - anything else (`false`, `'control'`, `null`, `undefined`) → not treatment
17+
*
18+
* Use this everywhere the flag's value is read so the PostHog config can
19+
* migrate to multivariate without a coordinated frontend deploy.
20+
*/
21+
export const isInDataApiRevokeTreatment = (flag: boolean | string | undefined): boolean => {
22+
if (flag === true) return true
23+
if (flag === 'test') return true
24+
return false
25+
}
26+
727
/**
828
* Controls the default state of the "Automatically expose new tables"
929
* checkbox at project creation. When the flag is on, the checkbox defaults
1030
* to unchecked (i.e. revoke SQL runs). When off/absent, the checkbox defaults
1131
* to checked (current behaviour — default grants remain).
1232
*/
1333
export const useDataApiRevokeOnCreateDefaultEnabled = (): boolean => {
14-
const flag = usePHFlag<boolean>('dataApiRevokeOnCreateDefault')
34+
const flag = usePHFlag<boolean | string>('dataApiRevokeOnCreateDefault')
1535

1636
// Preserve current behaviour (default grants remain) in tests so existing
1737
// E2E flows don't change silently. Tests that need the revoke-default path
@@ -20,7 +40,7 @@ export const useDataApiRevokeOnCreateDefaultEnabled = (): boolean => {
2040
return false
2141
}
2242

23-
return !!flag
43+
return isInDataApiRevokeTreatment(flag)
2444
}
2545

2646
type DefaultPrivilegesExposureOptions =
@@ -48,7 +68,7 @@ type DefaultPrivilegesExposureOptions =
4868
*/
4969
export const useTrackDefaultPrivilegesExposure = (options: DefaultPrivilegesExposureOptions) => {
5070
const track = useTrack()
51-
const flag = usePHFlag<boolean>('dataApiRevokeOnCreateDefault')
71+
const flag = usePHFlag<boolean | string>('dataApiRevokeOnCreateDefault')
5272
const hasTracked = useRef(false)
5373

5474
const { surface, dataApiDefaultPrivileges, hasUserModified } = options
@@ -59,7 +79,8 @@ export const useTrackDefaultPrivilegesExposure = (options: DefaultPrivilegesExpo
5979
if (flag === undefined) return
6080
if (surface === 'vercel' && !orgSlug) return
6181
// Gate on form-flag convergence unless the user explicitly dirtied the field.
62-
if (!hasUserModified && dataApiDefaultPrivileges !== !flag) return
82+
const expectedDefault = !isInDataApiRevokeTreatment(flag)
83+
if (!hasUserModified && dataApiDefaultPrivileges !== expectedDefault) return
6384
hasTracked.current = true
6485
track(
6586
'project_creation_default_privileges_exposed',

apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { useVercelProjectsQuery } from '@/data/integrations/integrations-vercel-
2828
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
2929
import { useProjectCreateMutation } from '@/data/projects/project-create-mutation'
3030
import {
31+
isInDataApiRevokeTreatment,
3132
useDataApiRevokeOnCreateDefaultEnabled,
3233
useTrackDefaultPrivilegesExposure,
3334
} from '@/hooks/misc/useDataApiRevokeOnCreateDefault'
@@ -82,7 +83,9 @@ const CreateProject = () => {
8283
const track = useTrack()
8384
const snapshot = useIntegrationInstallationSnapshot()
8485
const isDataApiRevokeOnCreateDefault = useDataApiRevokeOnCreateDefaultEnabled()
85-
const dataApiRevokeOnCreateDefaultFlag = usePHFlag<boolean>('dataApiRevokeOnCreateDefault')
86+
const dataApiRevokeOnCreateDefaultFlag = usePHFlag<boolean | string>(
87+
'dataApiRevokeOnCreateDefault'
88+
)
8689
const [dataApiDefaultPrivileges, setDataApiDefaultPrivileges] = useState(
8790
!isDataApiRevokeOnCreateDefault
8891
)
@@ -91,7 +94,7 @@ const CreateProject = () => {
9194
useEffect(() => {
9295
if (dataApiRevokeOnCreateDefaultFlag === undefined) return
9396
if (hasUserModifiedDataApiDefaultPrivileges.current) return
94-
setDataApiDefaultPrivileges(!dataApiRevokeOnCreateDefaultFlag)
97+
setDataApiDefaultPrivileges(!isInDataApiRevokeTreatment(dataApiRevokeOnCreateDefaultFlag))
9598
}, [dataApiRevokeOnCreateDefaultFlag])
9699

97100
const { slug, next, currentProjectId: foreignProjectId, externalId } = useParams()

apps/studio/pages/new/[slug].tsx

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@ import {
5858
import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
5959
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
6060
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
61-
import { useDataApiRevokeOnCreateDefaultEnabled } from '@/hooks/misc/useDataApiRevokeOnCreateDefault'
61+
import {
62+
isInDataApiRevokeTreatment,
63+
useDataApiRevokeOnCreateDefaultEnabled,
64+
} from '@/hooks/misc/useDataApiRevokeOnCreateDefault'
6265
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
6366
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
6467
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
@@ -108,8 +111,11 @@ const Wizard: NextPageWithLayout = () => {
108111

109112
// Read the raw flag for telemetry — coerce-undefined-to-false would record false for
110113
// users whose flags haven't loaded yet. The raw value preserves undefined (omitted from
111-
// PostHog) so we only record true/false when the flag is resolved.
112-
const dataApiRevokeOnCreateDefaultFlag = usePHFlag<boolean>('dataApiRevokeOnCreateDefault')
114+
// PostHog) so we only record an actual value (boolean true/false, or a variant string
115+
// like 'test'/'control' post-multivariate migration) once the flag has resolved.
116+
const dataApiRevokeOnCreateDefaultFlag = usePHFlag<boolean | string>(
117+
'dataApiRevokeOnCreateDefault'
118+
)
113119
const isDataApiRevokeOnCreateDefault = useDataApiRevokeOnCreateDefaultEnabled()
114120

115121
const isNotOnHigherPlan = !['team', 'enterprise', 'platform'].includes(currentOrg?.plan.id ?? '')
@@ -174,9 +180,13 @@ const Wizard: NextPageWithLayout = () => {
174180
useEffect(() => {
175181
if (dataApiRevokeOnCreateDefaultFlag === undefined) return
176182
if (isDataApiDefaultPrivilegesDirty) return
177-
setValue('dataApiDefaultPrivileges', !dataApiRevokeOnCreateDefaultFlag, {
178-
shouldDirty: false,
179-
})
183+
setValue(
184+
'dataApiDefaultPrivileges',
185+
!isInDataApiRevokeTreatment(dataApiRevokeOnCreateDefaultFlag),
186+
{
187+
shouldDirty: false,
188+
}
189+
)
180190
}, [dataApiRevokeOnCreateDefaultFlag, isDataApiDefaultPrivilegesDirty, setValue])
181191

182192
// [Charis] Since the form is updated in a useEffect, there is an edge case

packages/common/telemetry-constants.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -319,10 +319,13 @@ export interface ProjectCreationDefaultPrivilegesExposedEvent {
319319
dataApiDefaultPrivileges: boolean
320320
/**
321321
* Raw value of the dataApiRevokeOnCreateDefault PostHog flag at exposure time.
322-
* true = revoke cohort (checkbox defaulted to unchecked)
323-
* false = control cohort (checkbox defaulted to checked)
322+
* Accepts boolean (current rollout shape) or string (post-multivariate-migration
323+
* variant name, e.g. 'test' / 'control'). See GROWTH-877 for the migration plan.
324+
* true | 'test' = revoke cohort (checkbox defaulted to unchecked)
325+
* false = outside the rollout (checkbox defaulted to checked)
326+
* 'control' = in-experiment control arm (checkbox defaulted to checked)
324327
*/
325-
dataApiRevokeOnCreateDefaultEnabled: boolean
328+
dataApiRevokeOnCreateDefaultEnabled: boolean | string
326329
}
327330
groups: Omit<TelemetryGroups, 'project'>
328331
}
@@ -381,14 +384,17 @@ export interface ProjectCreationSimpleVersionSubmittedEvent {
381384
*/
382385
dataApiDefaultPrivilegesGranted?: boolean
383386
/**
384-
* Whether the dataApiRevokeOnCreateDefault PostHog flag was enabled for this user.
387+
* Raw value of the dataApiRevokeOnCreateDefault PostHog flag at submission time.
385388
* Controls only the default checkbox state of "Automatically expose new tables and functions"
386389
* at project creation. Tracking it lets us correlate flag cohort with user choice.
387-
* true = user is in the staged rollout cohort (checkbox defaulted to unchecked)
390+
* Accepts boolean (current rollout shape) or string (post-multivariate-migration
391+
* variant name, e.g. 'test' / 'control'). See GROWTH-877 for the migration plan.
392+
* true | 'test' = user is in the treatment arm (checkbox defaulted to unchecked)
388393
* false = user is outside the rollout (checkbox defaulted to checked)
394+
* 'control' = in-experiment control arm (checkbox defaulted to checked)
389395
* omitted = PostHog flags had not loaded at the time of project creation
390396
*/
391-
dataApiRevokeOnCreateDefaultEnabled?: boolean
397+
dataApiRevokeOnCreateDefaultEnabled?: boolean | string
392398
}
393399
groups: TelemetryGroups
394400
}

0 commit comments

Comments
 (0)