From 3396652921ab0e0f587b6afcb8d44f45ec6977df Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 6 May 2026 18:42:42 +0200 Subject: [PATCH 01/40] feat: add random sticky rollouts --- cli/src/api/channels.ts | 4 +- cli/src/bundle/upload.ts | 4 +- cli/src/channel/currentBundle.ts | 2 +- cli/src/channel/set.ts | 132 ++++++- cli/src/index.ts | 19 + cli/src/mcp/server.ts | 23 +- cli/src/schemas/channel.ts | 19 + cli/src/schemas/sdk.ts | 19 + cli/src/sdk.ts | 19 + cli/src/types/supabase.types.ts | 62 ++++ cli/src/utils.ts | 4 +- messages/en.json | 16 +- src/components/tables/BundleTable.vue | 4 +- src/components/tables/ChannelTable.vue | 2 +- .../app/[app].channel.[channel].devices.vue | 2 +- .../app/[app].channel.[channel].history.vue | 2 +- .../app/[app].channel.[channel].preview.vue | 2 +- .../[app].channel.[channel].statistics.vue | 2 +- src/pages/app/[app].channel.[channel].vue | 191 +++++++++- src/pages/app/[app].device.[device].vue | 2 +- src/services/supabase.ts | 4 +- src/types/supabase.types.ts | 62 ++++ .../_backend/private/channel_stats.ts | 2 +- .../functions/_backend/public/channel/get.ts | 82 ++++- .../functions/_backend/public/channel/post.ts | 80 ++++- .../triggers/cron_rollout_auto_pause.ts | 184 ++++++++++ supabase/functions/_backend/utils/pg.ts | 245 +++++++++++-- .../_backend/utils/postgres_schema.ts | 21 +- supabase/functions/_backend/utils/rollout.ts | 327 ++++++++++++++++++ .../_backend/utils/supabase.types.ts | 62 ++++ supabase/functions/_backend/utils/update.ts | 4 +- supabase/functions/triggers/index.ts | 2 + .../20260506154832_random_sticky_rollouts.sql | 312 +++++++++++++++++ tests/audit-logs.test.ts | 2 +- tests/channel_self.test.ts | 10 +- tests/cli-channel.test.ts | 4 +- tests/cli-min-version.test.ts | 2 +- tests/cli-sdk-utils.ts | 2 +- tests/expose-metadata.test.ts | 2 +- tests/rollout.test.ts | 187 ++++++++++ tests/updates.test.ts | 2 +- 41 files changed, 2053 insertions(+), 75 deletions(-) create mode 100644 supabase/functions/_backend/triggers/cron_rollout_auto_pause.ts create mode 100644 supabase/functions/_backend/utils/rollout.ts create mode 100644 supabase/migrations/20260506154832_random_sticky_rollouts.sql create mode 100644 tests/rollout.test.ts diff --git a/cli/src/api/channels.ts b/cli/src/api/channels.ts index 038fcbbd83..62fbd1aa0b 100644 --- a/cli/src/api/channels.ts +++ b/cli/src/api/channels.ts @@ -198,7 +198,7 @@ export function findBundleIdByChannelName(supabase: SupabaseClient, ap .from('channels') .select(` id, - version (id, name) + version:app_versions!channels_version_fkey(id, name) `) .eq('app_id', appId) .eq('name', name) @@ -263,7 +263,7 @@ export async function getActiveChannels( created_at, created_by, app_id, - version (id, name) + version:app_versions!channels_version_fkey(id, name) `) .eq('app_id', appid) .order('created_at', { ascending: false }) diff --git a/cli/src/bundle/upload.ts b/cli/src/bundle/upload.ts index 25fde76e64..b40d6d23a2 100644 --- a/cli/src/bundle/upload.ts +++ b/cli/src/bundle/upload.ts @@ -137,7 +137,7 @@ async function verifyCompatibility(supabase: SupabaseType, pm: pmType, options: const { data: channelData, error: channelError } = await supabase .from('channels') - .select('disable_auto_update, version ( min_update_version, native_packages )') + .select('disable_auto_update, version:app_versions!channels_version_fkey( min_update_version, native_packages )') .eq('name', channel) .eq('app_id', appid) .maybeSingle() @@ -616,7 +616,7 @@ type LinkedChannelVersion = { async function getLinkedBundleOnChannel(supabase: SupabaseType, appid: string, channel: string): Promise { const { data, error } = await supabase .from('channels') - .select('version ( id, name, deleted )') + .select('version:app_versions!channels_version_fkey( id, name, deleted )') .eq('app_id', appid) .eq('name', channel) diff --git a/cli/src/channel/currentBundle.ts b/cli/src/channel/currentBundle.ts index c9767b930c..6405029765 100644 --- a/cli/src/channel/currentBundle.ts +++ b/cli/src/channel/currentBundle.ts @@ -51,7 +51,7 @@ export async function currentBundleInternal(channel: string, appId: string, opti const { data: supabaseChannel, error } = await supabase .from('channels') - .select('version ( name )') + .select('version:app_versions!channels_version_fkey( name )') .eq('name', channel) .eq('app_id', appId) .limit(1) diff --git a/cli/src/channel/set.ts b/cli/src/channel/set.ts index 3d43f40b9d..915444e9af 100644 --- a/cli/src/channel/set.ts +++ b/cli/src/channel/set.ts @@ -97,6 +97,25 @@ export async function setChannelInternal(channel: string, appId: string, options emulator, device, prod, + rolloutBundle, + rolloutPercentage, + rolloutPercentageBps, + rolloutEnable, + rolloutDisable, + rolloutPause, + rolloutResume, + rolloutRollback, + rolloutPromote, + rolloutCacheTtlSeconds, + autoPauseEnabled, + autoPauseDisabled, + autoPauseWindowMinutes, + autoPauseFailureRateBps, + autoPauseConfidence, + autoPauseMinAttempts, + autoPauseMinFailures, + autoPauseAction, + autoPauseCooldownMinutes, } = options if (latest && bundle) { @@ -131,6 +150,25 @@ export async function setChannelInternal(channel: string, appId: string, options && device == null && prod == null && disableAutoUpdate == null + && rolloutBundle == null + && rolloutPercentage == null + && rolloutPercentageBps == null + && rolloutEnable == null + && rolloutDisable == null + && rolloutPause == null + && rolloutResume == null + && rolloutRollback == null + && rolloutPromote == null + && rolloutCacheTtlSeconds == null + && autoPauseEnabled == null + && autoPauseDisabled == null + && autoPauseWindowMinutes == null + && autoPauseFailureRateBps === undefined + && autoPauseConfidence == null + && autoPauseMinAttempts === undefined + && autoPauseMinFailures === undefined + && autoPauseAction == null + && autoPauseCooldownMinutes == null ) { if (!silent) log.error('Missing argument, you need to provide a option to set') @@ -147,7 +185,7 @@ export async function setChannelInternal(channel: string, appId: string, options version: undefined as any, } - const { error: channelError } = await supabase + const { data: existingChannel, error: channelError } = await supabase .from('channels') .select() .eq('app_id', appId) @@ -164,6 +202,25 @@ export async function setChannelInternal(channel: string, appId: string, options ? (extConfig?.config?.plugins?.CapacitorUpdater?.version || getBundleVersion('', options.packageJson)) : bundle + async function findRemoteBundle(versionName: string) { + const { data, error: vError } = await supabase + .from('app_versions') + .select() + .eq('app_id', appId) + .eq('name', versionName) + .eq('user_id', userId) + .eq('deleted', false) + .single() + + if (vError || !data) { + if (!silent) + log.error(`Cannot find version ${versionName}`) + throw new Error(`Cannot find version ${versionName}`) + } + + return data + } + if (resolvedBundleVersion != null) { const { data, error: vError } = await supabase .from('app_versions') @@ -259,6 +316,79 @@ export async function setChannelInternal(channel: string, appId: string, options channelPayload.version = data.id } + if (rolloutBundle != null) { + const data = await findRemoteBundle(rolloutBundle) + channelPayload.rollout_version = data.id + if (rolloutEnable == null) + channelPayload.rollout_enabled = true + if (!silent) + log.info(`Set ${appId} channel: ${channel} rollout target to @${rolloutBundle}`) + } + + const finalRolloutPercentageBps = rolloutPercentageBps ?? (rolloutPercentage == null ? undefined : Math.round(rolloutPercentage * 100)) + if (finalRolloutPercentageBps != null) { + if (finalRolloutPercentageBps < 0 || finalRolloutPercentageBps > 10000) + throw new Error('Rollout percentage must be between 0 and 100') + channelPayload.rollout_percentage_bps = finalRolloutPercentageBps + } + + if (rolloutEnable != null) + channelPayload.rollout_enabled = !!rolloutEnable + if (rolloutDisable) + channelPayload.rollout_enabled = false + + if (rolloutPause) { + channelPayload.rollout_paused_at = new Date().toISOString() + channelPayload.rollout_pause_reason = 'Paused from CLI' + } + + if (rolloutResume) { + channelPayload.rollout_paused_at = null + channelPayload.rollout_pause_reason = null + } + + if (rolloutRollback) { + channelPayload.rollout_version = null + channelPayload.rollout_enabled = false + channelPayload.rollout_percentage_bps = 0 + channelPayload.rollout_paused_at = null + channelPayload.rollout_pause_reason = null + } + + if (rolloutPromote) { + const rolloutVersion = channelPayload.rollout_version ?? existingChannel?.rollout_version + if (!rolloutVersion) + throw new Error('Cannot promote rollout without a rollout target') + channelPayload.version = rolloutVersion + channelPayload.rollout_version = null + channelPayload.rollout_enabled = false + channelPayload.rollout_percentage_bps = 0 + channelPayload.rollout_paused_at = null + channelPayload.rollout_pause_reason = null + } + + if (rolloutCacheTtlSeconds != null) + channelPayload.rollout_cache_ttl_seconds = rolloutCacheTtlSeconds + + if (autoPauseEnabled != null) + channelPayload.auto_pause_enabled = !!autoPauseEnabled + if (autoPauseDisabled) + channelPayload.auto_pause_enabled = false + if (autoPauseWindowMinutes != null) + channelPayload.auto_pause_window_minutes = autoPauseWindowMinutes + if (autoPauseFailureRateBps !== undefined) + channelPayload.auto_pause_failure_rate_bps = autoPauseFailureRateBps + if (autoPauseConfidence != null) + channelPayload.auto_pause_confidence = autoPauseConfidence as any + if (autoPauseMinAttempts !== undefined) + channelPayload.auto_pause_min_attempts = autoPauseMinAttempts + if (autoPauseMinFailures !== undefined) + channelPayload.auto_pause_min_failures = autoPauseMinFailures + if (autoPauseAction != null) + channelPayload.auto_pause_action = autoPauseAction + if (autoPauseCooldownMinutes != null) + channelPayload.auto_pause_cooldown_minutes = autoPauseCooldownMinutes + if (state != null) { if (state !== 'normal' && state !== 'default') { if (!silent) diff --git a/cli/src/index.ts b/cli/src/index.ts index dc05157a16..6308321908 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -492,6 +492,25 @@ Example: npx @capgo/cli@latest channel set production com.example.app --bundle 1 .option('--self-assign', `Allow device to self-assign to this channel`) .option('--no-self-assign', `Disable devices to self-assign to this channel`) .option('--disable-auto-update ', `Block updates by type: major, minor, metadata, patch, or none (allows all)`) + .option('--rollout-bundle ', `Bundle version to release gradually on this channel`) + .option('--rollout-percentage ', `Rollout percentage from 0 to 100`, value => Number.parseFloat(value)) + .option('--rollout-percentage-bps ', `Rollout percentage in basis points from 0 to 10000`, value => Number.parseInt(value, 10)) + .option('--rollout-enable', `Enable the configured rollout`) + .option('--rollout-disable', `Disable the configured rollout`) + .option('--rollout-pause', `Pause rollout exposure without rolling back selected devices`) + .option('--rollout-resume', `Resume a paused rollout`) + .option('--rollout-rollback', `Clear rollout state and return devices to stable`) + .option('--rollout-promote', `Promote rollout target to stable and clear rollout state`) + .option('--rollout-cache-ttl-seconds ', `Cloudflare rollout decision cache TTL in seconds`, value => Number.parseInt(value, 10)) + .option('--auto-pause-enabled', `Enable rollout auto-pause policy`) + .option('--auto-pause-disabled', `Disable rollout auto-pause policy`) + .option('--auto-pause-window-minutes ', `Stats window for rollout auto-pause`, value => Number.parseInt(value, 10)) + .option('--auto-pause-failure-rate-bps ', `Failure-rate threshold in basis points`, value => Number.parseInt(value, 10)) + .option('--auto-pause-confidence ', `Confidence level between 0 and 1`, value => Number.parseFloat(value)) + .option('--auto-pause-min-attempts ', `Minimum install plus fail attempts before auto-pause can trigger`, value => Number.parseInt(value, 10)) + .option('--auto-pause-min-failures ', `Minimum failures before auto-pause can trigger`, value => Number.parseInt(value, 10)) + .option('--auto-pause-action ', `Auto-pause action: pause, rollback, or notify`) + .option('--auto-pause-cooldown-minutes ', `Cooldown before auto-pause can trigger again`, value => Number.parseInt(value, 10)) .option('--dev', `Allow sending update to development devices`) .option('--no-dev', `Disable sending update to development devices`) .option('--prod', `Allow sending update to production devices`) diff --git a/cli/src/mcp/server.ts b/cli/src/mcp/server.ts index 80a6b6d87f..da370d7f90 100644 --- a/cli/src/mcp/server.ts +++ b/cli/src/mcp/server.ts @@ -334,8 +334,8 @@ export async function startMcpServer(): Promise { server.tool( 'capgo_update_channel', 'Update channel settings including linked bundle and targeting options', - updateChannelOptionsSchema.pick({ appId: true, channelId: true, bundle: true, state: true, downgrade: true, ios: true, android: true, selfAssign: true, disableAutoUpdate: true, dev: true, emulator: true, device: true, prod: true }).shape, - async ({ appId, channelId, bundle, state, downgrade, ios, android, selfAssign, disableAutoUpdate, dev, emulator, device, prod }) => { + updateChannelOptionsSchema.pick({ appId: true, channelId: true, bundle: true, state: true, downgrade: true, ios: true, android: true, selfAssign: true, disableAutoUpdate: true, dev: true, emulator: true, device: true, prod: true, rolloutBundle: true, rolloutPercentage: true, rolloutPercentageBps: true, rolloutEnable: true, rolloutDisable: true, rolloutPause: true, rolloutResume: true, rolloutRollback: true, rolloutPromote: true, rolloutCacheTtlSeconds: true, autoPauseEnabled: true, autoPauseDisabled: true, autoPauseWindowMinutes: true, autoPauseFailureRateBps: true, autoPauseConfidence: true, autoPauseMinAttempts: true, autoPauseMinFailures: true, autoPauseAction: true, autoPauseCooldownMinutes: true }).shape, + async ({ appId, channelId, bundle, state, downgrade, ios, android, selfAssign, disableAutoUpdate, dev, emulator, device, prod, rolloutBundle, rolloutPercentage, rolloutPercentageBps, rolloutEnable, rolloutDisable, rolloutPause, rolloutResume, rolloutRollback, rolloutPromote, rolloutCacheTtlSeconds, autoPauseEnabled, autoPauseDisabled, autoPauseWindowMinutes, autoPauseFailureRateBps, autoPauseConfidence, autoPauseMinAttempts, autoPauseMinFailures, autoPauseAction, autoPauseCooldownMinutes }) => { const result = await sdk.updateChannel({ appId, channelId, @@ -350,6 +350,25 @@ export async function startMcpServer(): Promise { emulator, device, prod, + rolloutBundle, + rolloutPercentage, + rolloutPercentageBps, + rolloutEnable, + rolloutDisable, + rolloutPause, + rolloutResume, + rolloutRollback, + rolloutPromote, + rolloutCacheTtlSeconds, + autoPauseEnabled, + autoPauseDisabled, + autoPauseWindowMinutes, + autoPauseFailureRateBps, + autoPauseConfidence, + autoPauseMinAttempts, + autoPauseMinFailures, + autoPauseAction, + autoPauseCooldownMinutes, }) if (!result.success) { return formatMcpError(result) diff --git a/cli/src/schemas/channel.ts b/cli/src/schemas/channel.ts index 629e8049cf..b312b23beb 100644 --- a/cli/src/schemas/channel.ts +++ b/cli/src/schemas/channel.ts @@ -64,6 +64,25 @@ export const optionsSetChannelSchema = optionsBaseSchema.extend({ prod: z.boolean().optional(), packageJson: z.string().optional(), ignoreMetadataCheck: z.boolean().optional(), + rolloutBundle: z.string().optional(), + rolloutPercentage: z.number().optional(), + rolloutPercentageBps: z.number().optional(), + rolloutEnable: z.boolean().optional(), + rolloutDisable: z.boolean().optional(), + rolloutPause: z.boolean().optional(), + rolloutResume: z.boolean().optional(), + rolloutRollback: z.boolean().optional(), + rolloutPromote: z.boolean().optional(), + rolloutCacheTtlSeconds: z.number().optional(), + autoPauseEnabled: z.boolean().optional(), + autoPauseDisabled: z.boolean().optional(), + autoPauseWindowMinutes: z.number().optional(), + autoPauseFailureRateBps: z.number().nullable().optional(), + autoPauseConfidence: z.number().optional(), + autoPauseMinAttempts: z.number().nullable().optional(), + autoPauseMinFailures: z.number().nullable().optional(), + autoPauseAction: z.enum(['pause', 'rollback', 'notify']).optional(), + autoPauseCooldownMinutes: z.number().optional(), }) export type OptionsSetChannel = z.infer diff --git a/cli/src/schemas/sdk.ts b/cli/src/schemas/sdk.ts index f47c023470..920662389b 100644 --- a/cli/src/schemas/sdk.ts +++ b/cli/src/schemas/sdk.ts @@ -201,6 +201,25 @@ export const updateChannelOptionsSchema = z.object({ emulator: z.boolean().optional(), device: z.boolean().optional(), prod: z.boolean().optional(), + rolloutBundle: z.string().optional(), + rolloutPercentage: z.number().optional(), + rolloutPercentageBps: z.number().optional(), + rolloutEnable: z.boolean().optional(), + rolloutDisable: z.boolean().optional(), + rolloutPause: z.boolean().optional(), + rolloutResume: z.boolean().optional(), + rolloutRollback: z.boolean().optional(), + rolloutPromote: z.boolean().optional(), + rolloutCacheTtlSeconds: z.number().optional(), + autoPauseEnabled: z.boolean().optional(), + autoPauseDisabled: z.boolean().optional(), + autoPauseWindowMinutes: z.number().optional(), + autoPauseFailureRateBps: z.number().nullable().optional(), + autoPauseConfidence: z.number().optional(), + autoPauseMinAttempts: z.number().nullable().optional(), + autoPauseMinFailures: z.number().nullable().optional(), + autoPauseAction: z.enum(['pause', 'rollback', 'notify']).optional(), + autoPauseCooldownMinutes: z.number().optional(), apikey: z.string().optional(), supaHost: z.string().optional(), supaAnon: z.string().optional(), diff --git a/cli/src/sdk.ts b/cli/src/sdk.ts index 1f32321e56..c7662a91ec 100644 --- a/cli/src/sdk.ts +++ b/cli/src/sdk.ts @@ -809,6 +809,25 @@ export class CapgoSDK { emulator: options.emulator, device: options.device, prod: options.prod, + rolloutBundle: options.rolloutBundle, + rolloutPercentage: options.rolloutPercentage, + rolloutPercentageBps: options.rolloutPercentageBps, + rolloutEnable: options.rolloutEnable, + rolloutDisable: options.rolloutDisable, + rolloutPause: options.rolloutPause, + rolloutResume: options.rolloutResume, + rolloutRollback: options.rolloutRollback, + rolloutPromote: options.rolloutPromote, + rolloutCacheTtlSeconds: options.rolloutCacheTtlSeconds, + autoPauseEnabled: options.autoPauseEnabled, + autoPauseDisabled: options.autoPauseDisabled, + autoPauseWindowMinutes: options.autoPauseWindowMinutes, + autoPauseFailureRateBps: options.autoPauseFailureRateBps, + autoPauseConfidence: options.autoPauseConfidence, + autoPauseMinAttempts: options.autoPauseMinAttempts, + autoPauseMinFailures: options.autoPauseMinFailures, + autoPauseAction: options.autoPauseAction, + autoPauseCooldownMinutes: options.autoPauseCooldownMinutes, latest: false, latestRemote: false, packageJson: undefined, diff --git a/cli/src/types/supabase.types.ts b/cli/src/types/supabase.types.ts index 3e18819df0..a49d61e1ed 100644 --- a/cli/src/types/supabase.types.ts +++ b/cli/src/types/supabase.types.ts @@ -266,6 +266,7 @@ export type Database = { ios_store_url: string | null last_version: string | null manifest_bundle_count: number + rollout_channel_count: number name: string | null need_onboarding: boolean owner_org: string @@ -291,6 +292,7 @@ export type Database = { ios_store_url?: string | null last_version?: string | null manifest_bundle_count?: number + rollout_channel_count?: number name?: string | null need_onboarding?: boolean owner_org: string @@ -316,6 +318,7 @@ export type Database = { ios_store_url?: string | null last_version?: string | null manifest_bundle_count?: number + rollout_channel_count?: number name?: string | null need_onboarding?: boolean owner_org?: string @@ -704,6 +707,23 @@ export type Database = { name: string owner_org: string public: boolean + auto_pause_action: string + auto_pause_confidence: number + auto_pause_cooldown_minutes: number + auto_pause_enabled: boolean + auto_pause_failure_rate_bps: number | null + auto_pause_last_checked_at: string | null + auto_pause_last_triggered_at: string | null + auto_pause_min_attempts: number | null + auto_pause_min_failures: number | null + auto_pause_window_minutes: number + rollout_cache_ttl_seconds: number + rollout_enabled: boolean + rollout_id: string + rollout_pause_reason: string | null + rollout_paused_at: string | null + rollout_percentage_bps: number + rollout_version: number | null rbac_id: string updated_at: string version: number @@ -726,6 +746,23 @@ export type Database = { name: string owner_org: string public?: boolean + auto_pause_action?: string + auto_pause_confidence?: number + auto_pause_cooldown_minutes?: number + auto_pause_enabled?: boolean + auto_pause_failure_rate_bps?: number | null + auto_pause_last_checked_at?: string | null + auto_pause_last_triggered_at?: string | null + auto_pause_min_attempts?: number | null + auto_pause_min_failures?: number | null + auto_pause_window_minutes?: number + rollout_cache_ttl_seconds?: number + rollout_enabled?: boolean + rollout_id?: string + rollout_pause_reason?: string | null + rollout_paused_at?: string | null + rollout_percentage_bps?: number + rollout_version?: number | null rbac_id?: string updated_at?: string version: number @@ -748,6 +785,23 @@ export type Database = { name?: string owner_org?: string public?: boolean + auto_pause_action?: string + auto_pause_confidence?: number + auto_pause_cooldown_minutes?: number + auto_pause_enabled?: boolean + auto_pause_failure_rate_bps?: number | null + auto_pause_last_checked_at?: string | null + auto_pause_last_triggered_at?: string | null + auto_pause_min_attempts?: number | null + auto_pause_min_failures?: number | null + auto_pause_window_minutes?: number + rollout_cache_ttl_seconds?: number + rollout_enabled?: boolean + rollout_id?: string + rollout_pause_reason?: string | null + rollout_paused_at?: string | null + rollout_percentage_bps?: number + rollout_version?: number | null rbac_id?: string updated_at?: string version?: number @@ -760,6 +814,13 @@ export type Database = { referencedRelation: "apps" referencedColumns: ["app_id"] }, + { + foreignKeyName: "channels_rollout_version_fkey" + columns: ["rollout_version"] + isOneToOne: false + referencedRelation: "app_versions" + referencedColumns: ["id"] + }, { foreignKeyName: "channels_version_fkey" columns: ["version"] @@ -3186,6 +3247,7 @@ export type Database = { ios_store_url: string | null last_version: string | null manifest_bundle_count: number + rollout_channel_count: number name: string | null need_onboarding: boolean owner_org: string diff --git a/cli/src/utils.ts b/cli/src/utils.ts index 29c25026e6..5850d2233d 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -2026,7 +2026,7 @@ interface ChannelChecksum { export async function getRemoteChecksums(supabase: SupabaseClient, appId: string, channel: string) { const { data, error } = await supabase .from('channels') - .select(`version(checksum)`) + .select(`version:app_versions!channels_version_fkey(checksum)`) .eq('name', channel) .eq('app_id', appId) .single() @@ -2069,7 +2069,7 @@ export function convertNativePackages(nativePackages: NativePackage[]): Map, appId: string, channel: string) { const { data: remoteNativePackages, error } = await supabase .from('channels') - .select(`version ( + .select(`version:app_versions!channels_version_fkey( native_packages )`) .eq('name', channel) diff --git a/messages/en.json b/messages/en.json index 320ee8eb8c..7aa91e5e6a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1878,5 +1878,19 @@ "restore-account": "Restore account", "restoring-account": "Restoring account...", "translation-not-ready": "Translation is being prepared. Try again in a bit.", - "translation-unavailable": "This language is not available right now." + "translation-unavailable": "This language is not available right now.", + "progressive-rollout": "Progressive rollout", + "set-rollout-target": "Set target", + "rollout-percentage": "Rollout percentage", + "rollout-target-linked": "Rollout target linked", + "invalid-rollout-percentage": "Rollout percentage must be between 0 and 100", + "manual-rollout-pause": "Paused manually", + "not-configured": "Not configured", + "pause": "Pause", + "resume": "Resume", + "promote": "Promote", + "notify": "Notify", + "auto-pause": "Auto-pause", + "failure-rate-bps": "Failure bps", + "current-rollout-target": "Current rollout target" } diff --git a/src/components/tables/BundleTable.vue b/src/components/tables/BundleTable.vue index c3a9f4b7ee..97d79e0def 100644 --- a/src/components/tables/BundleTable.vue +++ b/src/components/tables/BundleTable.vue @@ -323,7 +323,7 @@ async function deleteOne(one: Element) { // Check for linked channels const { data: channelFound, error: errorChannel } = await supabase .from('channels') - .select('id, name, version(name)') + .select('id, name, version:app_versions!channels_version_fkey(name)') .eq('app_id', one.app_id) .eq('version', one.id) @@ -485,7 +485,7 @@ async function massDelete() { return { data: (await supabase .from('channels') - .select('id, name, version(name)') + .select('id, name, version:app_versions!channels_version_fkey(name)') .eq('app_id', element.app_id) .eq('version', element.id)), element, diff --git a/src/components/tables/ChannelTable.vue b/src/components/tables/ChannelTable.vue index 46ed1c7239..285cf7e5ac 100644 --- a/src/components/tables/ChannelTable.vue +++ b/src/components/tables/ChannelTable.vue @@ -123,7 +123,7 @@ async function getData() { name, app_id, public, - version ( + version:app_versions!channels_version_fkey( id, name, created_at, diff --git a/src/pages/app/[app].channel.[channel].devices.vue b/src/pages/app/[app].channel.[channel].devices.vue index 2d09c6c534..772ca9097a 100644 --- a/src/pages/app/[app].channel.[channel].devices.vue +++ b/src/pages/app/[app].channel.[channel].devices.vue @@ -198,7 +198,7 @@ async function getChannel() { name, public, owner_org, - version ( + version:app_versions!channels_version_fkey( id, name, app_id, diff --git a/src/pages/app/[app].channel.[channel].history.vue b/src/pages/app/[app].channel.[channel].history.vue index 2d8e9c4120..f5d2ec01c0 100644 --- a/src/pages/app/[app].channel.[channel].history.vue +++ b/src/pages/app/[app].channel.[channel].history.vue @@ -44,7 +44,7 @@ async function getChannel() { name, public, owner_org, - version ( + version:app_versions!channels_version_fkey( id, name, app_id, diff --git a/src/pages/app/[app].channel.[channel].preview.vue b/src/pages/app/[app].channel.[channel].preview.vue index 4d807358f0..837ae3fb36 100644 --- a/src/pages/app/[app].channel.[channel].preview.vue +++ b/src/pages/app/[app].channel.[channel].preview.vue @@ -38,7 +38,7 @@ async function getChannel() { id, app_id, name, - version ( + version:app_versions!channels_version_fkey( id, name, manifest_count, diff --git a/src/pages/app/[app].channel.[channel].statistics.vue b/src/pages/app/[app].channel.[channel].statistics.vue index 5793d94a5d..52192de87c 100644 --- a/src/pages/app/[app].channel.[channel].statistics.vue +++ b/src/pages/app/[app].channel.[channel].statistics.vue @@ -425,7 +425,7 @@ async function getChannel() { name, public, owner_org, - version ( + version:app_versions!channels_version_fkey( id, name, app_id, diff --git a/src/pages/app/[app].channel.[channel].vue b/src/pages/app/[app].channel.[channel].vue index b3d4e0b4df..ad63b86244 100644 --- a/src/pages/app/[app].channel.[channel].vue +++ b/src/pages/app/[app].channel.[channel].vue @@ -26,6 +26,7 @@ import { useDisplayStore } from '~/stores/display' interface Channel { version: Database['public']['Tables']['app_versions']['Row'] + rollout_version_info?: Pick | null } type ChannelUpdate = Database['public']['Tables']['channels']['Update'] @@ -38,12 +39,27 @@ type EditableChannelKey = 'allow_dev' | 'disable_auto_update_under_native' | 'electron' | 'ios' + | 'rollout_cache_ttl_seconds' + | 'rollout_enabled' + | 'rollout_paused_at' + | 'rollout_pause_reason' + | 'rollout_percentage_bps' + | 'rollout_version' + | 'auto_pause_enabled' + | 'auto_pause_window_minutes' + | 'auto_pause_failure_rate_bps' + | 'auto_pause_confidence' + | 'auto_pause_min_attempts' + | 'auto_pause_min_failures' + | 'auto_pause_action' + | 'auto_pause_cooldown_minutes' | 'version' // Bundle link dialog state const bundleLinkVersions = ref([]) const bundleLinkSearchVal = ref('') const bundleLinkSearchMode = ref(false) +const bundleLinkMode = ref<'stable' | 'rollout'>('stable') const main = useMainStore() const route = useRoute('/app/[app].channel.[channel]') @@ -111,7 +127,7 @@ async function getChannel(force = false) { name, public, owner_org, - version ( + version:app_versions!channels_version_fkey( id, name, app_id, @@ -121,6 +137,26 @@ async function getChannel(force = false) { link, comment ), + rollout_version, + rollout_percentage_bps, + rollout_enabled, + rollout_id, + rollout_paused_at, + rollout_pause_reason, + rollout_cache_ttl_seconds, + auto_pause_enabled, + auto_pause_window_minutes, + auto_pause_failure_rate_bps, + auto_pause_confidence, + auto_pause_min_attempts, + auto_pause_min_failures, + auto_pause_action, + auto_pause_cooldown_minutes, + auto_pause_last_triggered_at, + rollout_version_info:app_versions!channels_rollout_version_fkey( + id, + name + ), created_at, app_id, allow_emulator, @@ -255,6 +291,13 @@ async function handleVersionLink(appVersion: Database['public']['Tables']['app_v else { toast.info(t('bundle-compatible-with-channel', { channel: channel.value.name })) } + if (bundleLinkMode.value === 'rollout') { + await saveChannelChange('rollout_version', appVersion.id as any) + await saveChannelChange('rollout_enabled', true as any) + toast.success(t('rollout-target-linked')) + return + } + await saveChannelChange('version', appVersion.id) toast.success(t('linked-bundle')) } @@ -404,6 +447,52 @@ async function openSelectVersion() { await dialogStore.onDialogDismiss() } +async function openSelectStableVersion() { + bundleLinkMode.value = 'stable' + await openSelectVersion() +} + +async function openSelectRolloutVersion() { + bundleLinkMode.value = 'rollout' + await openSelectVersion() +} + +async function saveRolloutPercentage(value: string) { + const percentage = Number.parseFloat(value) + if (Number.isNaN(percentage) || percentage < 0 || percentage > 100) { + toast.error(t('invalid-rollout-percentage')) + return + } + await saveChannelChange('rollout_percentage_bps', Math.round(percentage * 100) as any) +} + +async function rollbackRollout() { + await Promise.all([ + saveChannelChange('rollout_version', null as any), + saveChannelChange('rollout_enabled', false as any), + saveChannelChange('rollout_percentage_bps', 0 as any), + saveChannelChange('rollout_paused_at', null as any), + saveChannelChange('rollout_pause_reason', null as any), + ]) +} + +async function promoteRollout() { + if (!channel.value?.rollout_version) + return + await saveChannelChange('version', channel.value.rollout_version as any) + await rollbackRollout() +} + +async function toggleRolloutPause() { + if (channel.value?.rollout_paused_at) { + await saveChannelChange('rollout_paused_at', null as any) + await saveChannelChange('rollout_pause_reason', null as any) + return + } + await saveChannelChange('rollout_paused_at', new Date().toISOString() as any) + await saveChannelChange('rollout_pause_reason', t('manual-rollout-pause') as any) +} + async function refreshFilteredVersions() { if (!channel.value) return @@ -689,7 +778,7 @@ async function copyCurlCommand() { v-if="channel" class="p-1 transition-colors border border-gray-200 rounded-md dark:border-gray-700 hover:bg-gray-50 hover:border-gray-300 dark:hover:border-gray-600 dark:hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:border-gray-200 dark:disabled:hover:border-gray-700" :disabled="!canPromoteBundle" - @click="openSelectVersion()" + @click="openSelectStableVersion()" > @@ -719,6 +808,100 @@ async function copyCurlCommand() { {{ channel.version.comment }} + +
+
+ + {{ channel?.rollout_version_info?.name ?? t('not-configured') }} + + + {{ channel.rollout_pause_reason }} + +
+ + {{ channel.rollout_paused_at ? t('paused') : channel.rollout_enabled ? t('enabled') : t('disabled') }} + +
+ + + + + +
+
+
+ +
+ + % +
+
+ +
+ +
+ + +
+
+
- {{ t('current-bundle') }} + {{ bundleLinkMode === 'rollout' ? t('current-rollout-target') : t('current-bundle') }}
- {{ currentChannelVersion?.name || t('unknown') }} + {{ bundleLinkMode === 'rollout' ? (channel?.rollout_version_info?.name || t('not-configured')) : (currentChannelVersion?.name || t('unknown')) }}
diff --git a/src/pages/app/[app].device.[device].vue b/src/pages/app/[app].device.[device].vue index cc97c46f02..b3fd198c27 100644 --- a/src/pages/app/[app].device.[device].vue +++ b/src/pages/app/[app].device.[device].vue @@ -87,7 +87,7 @@ async function getChannelOverride() { channel_id ( name, id, - version ( + version:app_versions!channels_version_fkey( name, id ) diff --git a/src/services/supabase.ts b/src/services/supabase.ts index 3aff276c39..34f012cc1b 100644 --- a/src/services/supabase.ts +++ b/src/services/supabase.ts @@ -648,8 +648,8 @@ export function convertNativePackages(nativePackages: { name: string, version: s export async function getRemoteDependencies(appId: string, channel: string) { const { data: remoteNativePackages, error } = await useSupabase() .from('channels') - .select(`version ( - native_packages + .select(`version:app_versions!channels_version_fkey( + native_packages )`) .eq('name', channel) .eq('app_id', appId) diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index f301a5f2ac..d370b292d7 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -266,6 +266,7 @@ export type Database = { ios_store_url: string | null last_version: string | null manifest_bundle_count: number + rollout_channel_count: number name: string | null need_onboarding: boolean owner_org: string @@ -291,6 +292,7 @@ export type Database = { ios_store_url?: string | null last_version?: string | null manifest_bundle_count?: number + rollout_channel_count?: number name?: string | null need_onboarding?: boolean owner_org: string @@ -316,6 +318,7 @@ export type Database = { ios_store_url?: string | null last_version?: string | null manifest_bundle_count?: number + rollout_channel_count?: number name?: string | null need_onboarding?: boolean owner_org?: string @@ -704,6 +707,23 @@ export type Database = { name: string owner_org: string public: boolean + auto_pause_action: string + auto_pause_confidence: number + auto_pause_cooldown_minutes: number + auto_pause_enabled: boolean + auto_pause_failure_rate_bps: number | null + auto_pause_last_checked_at: string | null + auto_pause_last_triggered_at: string | null + auto_pause_min_attempts: number | null + auto_pause_min_failures: number | null + auto_pause_window_minutes: number + rollout_cache_ttl_seconds: number + rollout_enabled: boolean + rollout_id: string + rollout_pause_reason: string | null + rollout_paused_at: string | null + rollout_percentage_bps: number + rollout_version: number | null rbac_id: string updated_at: string version: number @@ -726,6 +746,23 @@ export type Database = { name: string owner_org: string public?: boolean + auto_pause_action?: string + auto_pause_confidence?: number + auto_pause_cooldown_minutes?: number + auto_pause_enabled?: boolean + auto_pause_failure_rate_bps?: number | null + auto_pause_last_checked_at?: string | null + auto_pause_last_triggered_at?: string | null + auto_pause_min_attempts?: number | null + auto_pause_min_failures?: number | null + auto_pause_window_minutes?: number + rollout_cache_ttl_seconds?: number + rollout_enabled?: boolean + rollout_id?: string + rollout_pause_reason?: string | null + rollout_paused_at?: string | null + rollout_percentage_bps?: number + rollout_version?: number | null rbac_id?: string updated_at?: string version: number @@ -748,6 +785,23 @@ export type Database = { name?: string owner_org?: string public?: boolean + auto_pause_action?: string + auto_pause_confidence?: number + auto_pause_cooldown_minutes?: number + auto_pause_enabled?: boolean + auto_pause_failure_rate_bps?: number | null + auto_pause_last_checked_at?: string | null + auto_pause_last_triggered_at?: string | null + auto_pause_min_attempts?: number | null + auto_pause_min_failures?: number | null + auto_pause_window_minutes?: number + rollout_cache_ttl_seconds?: number + rollout_enabled?: boolean + rollout_id?: string + rollout_pause_reason?: string | null + rollout_paused_at?: string | null + rollout_percentage_bps?: number + rollout_version?: number | null rbac_id?: string updated_at?: string version?: number @@ -760,6 +814,13 @@ export type Database = { referencedRelation: "apps" referencedColumns: ["app_id"] }, + { + foreignKeyName: "channels_rollout_version_fkey" + columns: ["rollout_version"] + isOneToOne: false + referencedRelation: "app_versions" + referencedColumns: ["id"] + }, { foreignKeyName: "channels_version_fkey" columns: ["version"] @@ -3225,6 +3286,7 @@ export type Database = { ios_store_url: string | null last_version: string | null manifest_bundle_count: number + rollout_channel_count: number name: string | null need_onboarding: boolean owner_org: string diff --git a/supabase/functions/_backend/private/channel_stats.ts b/supabase/functions/_backend/private/channel_stats.ts index d0bc69e299..930ae50e4c 100644 --- a/supabase/functions/_backend/private/channel_stats.ts +++ b/supabase/functions/_backend/private/channel_stats.ts @@ -146,7 +146,7 @@ app.post('/', middlewareAuth, async (c) => { const { data: channelData, error: channelError } = await supabase .from('channels') - .select('id, name, version, updated_at, version (name)') + .select('id, name, version, updated_at, version:app_versions!channels_version_fkey(name)') .eq('id', body.channel_id) .eq('app_id', body.app_id) .single() diff --git a/supabase/functions/_backend/public/channel/get.ts b/supabase/functions/_backend/public/channel/get.ts index 10e2bc4774..b49cc408a6 100644 --- a/supabase/functions/_backend/public/channel/get.ts +++ b/supabase/functions/_backend/public/channel/get.ts @@ -33,7 +33,29 @@ async function getAll(c: Context, body: GetDevice, apikey: Database['public']['T allow_device, allow_dev, allow_prod, - version ( + + rollout_version, + rollout_percentage_bps, + rollout_enabled, + rollout_id, + rollout_paused_at, + rollout_pause_reason, + rollout_cache_ttl_seconds, + auto_pause_enabled, + auto_pause_window_minutes, + auto_pause_failure_rate_bps, + auto_pause_confidence, + auto_pause_min_attempts, + auto_pause_min_failures, + auto_pause_action, + auto_pause_cooldown_minutes, + auto_pause_last_triggered_at, + auto_pause_last_checked_at, + rollout_version_info:app_versions!channels_rollout_version_fkey( + name, + id + ), + version:app_versions!channels_version_fkey( name, id ) @@ -45,11 +67,26 @@ async function getAll(c: Context, body: GetDevice, apikey: Database['public']['T throw simpleError('cannot_find_channels', 'Cannot find channels', { supabaseError: dbError }) } return c.json(dataChannels.map((o) => { - const { disable_auto_update_under_native, disable_auto_update, ...rest } = o + const { disable_auto_update_under_native, disable_auto_update, rollout_percentage_bps, rollout_enabled, rollout_paused_at, rollout_pause_reason, rollout_cache_ttl_seconds, auto_pause_enabled, auto_pause_window_minutes, auto_pause_failure_rate_bps, auto_pause_confidence, auto_pause_min_attempts, auto_pause_min_failures, auto_pause_action, auto_pause_cooldown_minutes, auto_pause_last_triggered_at, auto_pause_last_checked_at, ...rest } = o return { ...rest, disableAutoUpdateUnderNative: disable_auto_update_under_native, disableAutoUpdate: disable_auto_update, + rolloutPercentageBps: rollout_percentage_bps, + rolloutEnabled: rollout_enabled, + rolloutPausedAt: rollout_paused_at, + rolloutPauseReason: rollout_pause_reason, + rolloutCacheTtlSeconds: rollout_cache_ttl_seconds, + autoPauseEnabled: auto_pause_enabled, + autoPauseWindowMinutes: auto_pause_window_minutes, + autoPauseFailureRateBps: auto_pause_failure_rate_bps, + autoPauseConfidence: auto_pause_confidence, + autoPauseMinAttempts: auto_pause_min_attempts, + autoPauseMinFailures: auto_pause_min_failures, + autoPauseAction: auto_pause_action, + autoPauseCooldownMinutes: auto_pause_cooldown_minutes, + autoPauseLastTriggeredAt: auto_pause_last_triggered_at, + autoPauseLastCheckedAt: auto_pause_last_checked_at, } })) } @@ -73,7 +110,29 @@ async function getOne(c: Context, body: GetDevice, apikey: Database['public']['T allow_dev, allow_prod, public, - version ( + + rollout_version, + rollout_percentage_bps, + rollout_enabled, + rollout_id, + rollout_paused_at, + rollout_pause_reason, + rollout_cache_ttl_seconds, + auto_pause_enabled, + auto_pause_window_minutes, + auto_pause_failure_rate_bps, + auto_pause_confidence, + auto_pause_min_attempts, + auto_pause_min_failures, + auto_pause_action, + auto_pause_cooldown_minutes, + auto_pause_last_triggered_at, + auto_pause_last_checked_at, + rollout_version_info:app_versions!channels_rollout_version_fkey( + name, + id + ), + version:app_versions!channels_version_fkey( name, id ) @@ -85,11 +144,26 @@ async function getOne(c: Context, body: GetDevice, apikey: Database['public']['T throw simpleError('cannot_find_version', 'Cannot find version', { supabaseError: dbError }) } - const { disable_auto_update_under_native, disable_auto_update, ...rest } = dataChannel + const { disable_auto_update_under_native, disable_auto_update, rollout_percentage_bps, rollout_enabled, rollout_paused_at, rollout_pause_reason, rollout_cache_ttl_seconds, auto_pause_enabled, auto_pause_window_minutes, auto_pause_failure_rate_bps, auto_pause_confidence, auto_pause_min_attempts, auto_pause_min_failures, auto_pause_action, auto_pause_cooldown_minutes, auto_pause_last_triggered_at, auto_pause_last_checked_at, ...rest } = dataChannel const newObject = { ...rest, disableAutoUpdateUnderNative: disable_auto_update_under_native, disableAutoUpdate: disable_auto_update, + rolloutPercentageBps: rollout_percentage_bps, + rolloutEnabled: rollout_enabled, + rolloutPausedAt: rollout_paused_at, + rolloutPauseReason: rollout_pause_reason, + rolloutCacheTtlSeconds: rollout_cache_ttl_seconds, + autoPauseEnabled: auto_pause_enabled, + autoPauseWindowMinutes: auto_pause_window_minutes, + autoPauseFailureRateBps: auto_pause_failure_rate_bps, + autoPauseConfidence: auto_pause_confidence, + autoPauseMinAttempts: auto_pause_min_attempts, + autoPauseMinFailures: auto_pause_min_failures, + autoPauseAction: auto_pause_action, + autoPauseCooldownMinutes: auto_pause_cooldown_minutes, + autoPauseLastTriggeredAt: auto_pause_last_triggered_at, + autoPauseLastCheckedAt: auto_pause_last_checked_at, } return c.json(newObject) diff --git a/supabase/functions/_backend/public/channel/post.ts b/supabase/functions/_backend/public/channel/post.ts index 1ba116708b..dfa25896a6 100644 --- a/supabase/functions/_backend/public/channel/post.ts +++ b/supabase/functions/_backend/public/channel/post.ts @@ -22,6 +22,23 @@ interface ChannelSet { allow_device?: boolean allow_dev?: boolean allow_prod?: boolean + rolloutVersion?: string | null + rolloutPercentage?: number + rolloutPercentageBps?: number + rolloutEnabled?: boolean + rolloutPaused?: boolean + rolloutPauseReason?: string | null + rolloutCacheTtlSeconds?: number + rollback?: boolean + promoteToStable?: boolean + autoPauseEnabled?: boolean + autoPauseWindowMinutes?: number + autoPauseFailureRateBps?: number | null + autoPauseConfidence?: number + autoPauseMinAttempts?: number | null + autoPauseMinFailures?: number | null + autoPauseAction?: 'pause' | 'rollback' | 'notify' + autoPauseCooldownMinutes?: number } async function findVersion(c: Context, appID: string, version: string, ownerOrg: string, apikey: Database['public']['Tables']['apikeys']['Row']) { @@ -56,6 +73,26 @@ export async function post(c: Context, body: ChannelSet, throw simpleError('invalid_app_id', 'You can\'t access this app', { app_id: body.app_id }) } const inferredElectron = body.electron ?? (body.public && body.ios !== body.android ? false : undefined) + const rolloutPercentageBps = body.rolloutPercentageBps ?? (body.rolloutPercentage == null ? undefined : Math.round(body.rolloutPercentage * 100)) + if (rolloutPercentageBps != null && (rolloutPercentageBps < 0 || rolloutPercentageBps > 10000)) { + throw simpleError('invalid_rollout_percentage', 'Rollout percentage must be between 0 and 10000 basis points', { rolloutPercentageBps }) + } + if (body.autoPauseAction && !['pause', 'rollback', 'notify'].includes(body.autoPauseAction)) { + throw simpleError('invalid_auto_pause_action', 'Auto-pause action must be pause, rollback, or notify', { autoPauseAction: body.autoPauseAction }) + } + const shouldLoadExistingChannel = body.version === undefined || body.promoteToStable + let existingChannelVersion: number | null = null + let existingRolloutVersion: number | null = null + if (shouldLoadExistingChannel) { + const { data: existingChannel } = await supabaseApikey(c, apikey.key) + .from('channels') + .select('version, rollout_version') + .eq('app_id', body.app_id) + .eq('name', body.channel) + .maybeSingle() + existingChannelVersion = existingChannel?.version ?? null + existingRolloutVersion = existingChannel?.rollout_version ?? null + } const channel: Database['public']['Tables']['channels']['Insert'] = { created_by: apikey.user_id, app_id: body.app_id, @@ -71,11 +108,52 @@ export async function post(c: Context, body: ChannelSet, ...(body.ios == null ? {} : { ios: body.ios }), ...(body.android == null ? {} : { android: body.android }), ...(inferredElectron == null ? {} : { electron: inferredElectron }), + ...(rolloutPercentageBps == null ? {} : { rollout_percentage_bps: rolloutPercentageBps }), + ...(body.rolloutEnabled == null ? {} : { rollout_enabled: body.rolloutEnabled }), + ...(body.rolloutCacheTtlSeconds == null ? {} : { rollout_cache_ttl_seconds: body.rolloutCacheTtlSeconds }), + ...(body.rolloutPauseReason === undefined ? {} : { rollout_pause_reason: body.rolloutPauseReason }), + ...(body.rolloutPaused == null ? {} : { rollout_paused_at: body.rolloutPaused ? new Date().toISOString() : null, ...(body.rolloutPaused ? {} : { rollout_pause_reason: null }) }), + ...(body.autoPauseEnabled == null ? {} : { auto_pause_enabled: body.autoPauseEnabled }), + ...(body.autoPauseWindowMinutes == null ? {} : { auto_pause_window_minutes: body.autoPauseWindowMinutes }), + ...(body.autoPauseFailureRateBps === undefined ? {} : { auto_pause_failure_rate_bps: body.autoPauseFailureRateBps }), + ...(body.autoPauseConfidence == null ? {} : { auto_pause_confidence: body.autoPauseConfidence as any }), + ...(body.autoPauseMinAttempts === undefined ? {} : { auto_pause_min_attempts: body.autoPauseMinAttempts }), + ...(body.autoPauseMinFailures === undefined ? {} : { auto_pause_min_failures: body.autoPauseMinFailures }), + ...(body.autoPauseAction == null ? {} : { auto_pause_action: body.autoPauseAction }), + ...(body.autoPauseCooldownMinutes == null ? {} : { auto_pause_cooldown_minutes: body.autoPauseCooldownMinutes }), version: -1, owner_org: org.owner_org, } - channel.version = await findVersion(c, body.app_id, body.version ?? 'unknown', org.owner_org, apikey) + channel.version = body.version === undefined && existingChannelVersion !== null + ? existingChannelVersion + : await findVersion(c, body.app_id, body.version ?? 'unknown', org.owner_org, apikey) + + if (body.rolloutVersion !== undefined) { + channel.rollout_version = body.rolloutVersion ? await findVersion(c, body.app_id, body.rolloutVersion, org.owner_org, apikey) : null + } + + if (body.rollback) { + channel.rollout_version = null + channel.rollout_enabled = false + channel.rollout_percentage_bps = 0 + channel.rollout_paused_at = null + channel.rollout_pause_reason = null + } + + if (body.promoteToStable) { + const promotedVersion = channel.rollout_version ?? existingRolloutVersion + if (!promotedVersion) { + throw simpleError('missing_rollout_version', 'Cannot promote without a rollout version', { app_id: body.app_id, channel: body.channel }) + } + channel.version = promotedVersion + channel.rollout_version = null + channel.rollout_enabled = false + channel.rollout_percentage_bps = 0 + channel.rollout_paused_at = null + channel.rollout_pause_reason = null + } + await updateOrCreateChannel(c, channel) return c.json(BRES) } diff --git a/supabase/functions/_backend/triggers/cron_rollout_auto_pause.ts b/supabase/functions/_backend/triggers/cron_rollout_auto_pause.ts new file mode 100644 index 0000000000..4534fbd50b --- /dev/null +++ b/supabase/functions/_backend/triggers/cron_rollout_auto_pause.ts @@ -0,0 +1,184 @@ +import type { MiddlewareKeyVariables } from '../utils/hono.ts' +import { Hono } from 'hono/tiny' +import { BRES, middlewareAPISecret } from '../utils/hono.ts' +import { cloudlog, cloudlogErr } from '../utils/logging.ts' +import { evaluateAutoPausePolicy, type AutoPauseAction } from '../utils/rollout.ts' +import { readStatsVersion } from '../utils/stats.ts' +import { supabaseAdmin } from '../utils/supabase.ts' + +interface RolloutAutoPauseChannel { + app_id: string + auto_pause_action: string + auto_pause_confidence: number | string + auto_pause_cooldown_minutes: number + auto_pause_enabled: boolean + auto_pause_failure_rate_bps: number | null + auto_pause_last_triggered_at: string | null + auto_pause_min_attempts: number | null + auto_pause_min_failures: number | null + auto_pause_window_minutes: number + id: number + name: string + owner_org: string + rollout_version: number + rollout_version_info?: { name: string } | { name: string }[] | null +} + +export const app = new Hono() + +function normalizeAction(action: string): AutoPauseAction { + if (action === 'rollback' || action === 'notify') + return action + return 'pause' +} + +function getRolloutVersionName(channel: RolloutAutoPauseChannel): string | null { + const relation = channel.rollout_version_info + if (!relation) + return null + if (Array.isArray(relation)) + return relation[0]?.name ?? null + return relation.name ?? null +} + +function getWindowStart(windowMinutes: number, now: Date): string { + const minutes = Number.isFinite(windowMinutes) && windowMinutes > 0 ? windowMinutes : 60 + return new Date(now.getTime() - minutes * 60 * 1000).toISOString() +} + +function buildReason(channel: RolloutAutoPauseChannel, versionName: string, result: ReturnType) { + return `Auto-pause ${result.action} for ${channel.name} rollout ${versionName}: fail ${result.failureRateBps}bps, confidence lower bound ${result.lowerBoundBps}bps, threshold ${result.thresholdBps}bps, attempts ${result.attempts}.` +} + +async function evaluateChannel(c: Parameters[0], supabase: ReturnType, channel: RolloutAutoPauseChannel, now: Date) { + const versionName = getRolloutVersionName(channel) + if (!versionName) + return { skipped: true, reason: 'missing_rollout_version_name' } + + const start = getWindowStart(channel.auto_pause_window_minutes, now) + const stats = await readStatsVersion(c, channel.app_id, start, now.toISOString()) + const totals = stats + .filter(row => row.version_name === versionName) + .reduce((acc, row) => { + acc.installs += Number(row.install ?? 0) + acc.failures += Number(row.fail ?? 0) + return acc + }, { installs: 0, failures: 0 }) + + const result = evaluateAutoPausePolicy({ + action: normalizeAction(channel.auto_pause_action), + confidence: Number(channel.auto_pause_confidence ?? 0.95), + cooldownMinutes: channel.auto_pause_cooldown_minutes ?? 60, + enabled: channel.auto_pause_enabled, + failureRateBps: channel.auto_pause_failure_rate_bps, + failures: totals.failures, + installs: totals.installs, + lastTriggeredAt: channel.auto_pause_last_triggered_at, + minAttempts: channel.auto_pause_min_attempts, + minFailures: channel.auto_pause_min_failures, + now, + }) + + if (!result.shouldTrigger) { + await supabase + .from('channels') + .update({ auto_pause_last_checked_at: now.toISOString() } as any) + .eq('id', channel.id) + return { triggered: false, reason: result.reason, result } + } + + const reason = buildReason(channel, versionName, result) + const basePatch = { + auto_pause_last_checked_at: now.toISOString(), + auto_pause_last_triggered_at: now.toISOString(), + rollout_pause_reason: reason, + } as any + + if (result.action === 'pause') { + await supabase + .from('channels') + .update({ + ...basePatch, + rollout_paused_at: now.toISOString(), + }) + .eq('id', channel.id) + } + else if (result.action === 'rollback') { + await supabase + .from('channels') + .update({ + ...basePatch, + rollout_enabled: false, + rollout_percentage_bps: 0, + rollout_version: null, + rollout_paused_at: null, + }) + .eq('id', channel.id) + } + else { + await supabase + .from('channels') + .update(basePatch) + .eq('id', channel.id) + } + + cloudlog({ requestId: c.get('requestId'), message: 'rollout auto-pause triggered', appId: channel.app_id, channelId: channel.id, action: result.action, reason }) + return { triggered: true, reason, result } +} + +app.post('/', middlewareAPISecret, async (c) => { + const supabase = supabaseAdmin(c) + const now = new Date() + + const { data, error } = await supabase + .from('channels') + .select(` + id, + name, + app_id, + owner_org, + rollout_version, + auto_pause_enabled, + auto_pause_window_minutes, + auto_pause_failure_rate_bps, + auto_pause_confidence, + auto_pause_min_attempts, + auto_pause_min_failures, + auto_pause_action, + auto_pause_cooldown_minutes, + auto_pause_last_triggered_at, + rollout_version_info:app_versions!channels_rollout_version_fkey(name) + `) + .eq('rollout_enabled', true) + .eq('auto_pause_enabled', true) + .not('rollout_version', 'is', null) + + if (error) { + cloudlogErr({ requestId: c.get('requestId'), message: 'Cannot load rollout auto-pause channels', error }) + throw error + } + + const channels = (data ?? []) as unknown as RolloutAutoPauseChannel[] + let triggered = 0 + let checked = 0 + + for (const channel of channels) { + try { + const evaluation = await evaluateChannel(c, supabase, channel, now) + checked += 1 + if ('triggered' in evaluation && evaluation.triggered) + triggered += 1 + } + catch (error) { + cloudlogErr({ requestId: c.get('requestId'), message: 'Rollout auto-pause channel evaluation failed', channelId: channel.id, appId: channel.app_id, error }) + } + } + + return c.json({ ...BRES, checked, triggered }) +}) + +export const __rolloutAutoPauseTestUtils__ = { + buildReason, + getRolloutVersionName, + getWindowStart, +} diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index 085e2fce96..45c86e821d 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -10,6 +10,7 @@ import { CacheHelper } from './cache.ts' import { DISPOSABLE_EMAIL_DOMAINS, PERSONAL_EMAIL_DOMAINS } from './emailClassification.ts' import { getClientDbRegionSB } from './geolocation.ts' import { cloudlog, cloudlogErr } from './logging.ts' +import { getRolloutDecision } from './rollout.ts' import * as schema from './postgres_schema.ts' import { withOptionalManifestSelect } from './queryHelpers.ts' @@ -440,31 +441,38 @@ export function closeClient(c: Context, db: ReturnType) { export function getAlias() { const versionAlias = alias(schema.app_versions, 'version') + const rolloutVersionAlias = alias(schema.app_versions, 'rollout_version') const channelDevicesAlias = alias(schema.channel_devices, 'channel_devices') const channelAlias = alias(schema.channels, 'channels') - return { versionAlias, channelDevicesAlias, channelAlias } + return { versionAlias, rolloutVersionAlias, channelDevicesAlias, channelAlias } } -function getSchemaUpdatesAlias(includeMetadata = false) { - const { versionAlias, channelDevicesAlias, channelAlias } = getAlias() - +function getVersionSelect(versionAlias: any, prefix: string, includeMetadata = false) { const versionSelect: any = { - id: sql`${versionAlias.id}`.as('vid'), - name: sql`${versionAlias.name}`.as('vname'), - checksum: sql`${versionAlias.checksum}`.as('vchecksum'), - session_key: sql`${versionAlias.session_key}`.as('vsession_key'), - key_id: sql`${versionAlias.key_id}`.as('vkey_id'), - storage_provider: sql`${versionAlias.storage_provider}`.as('vstorage_provider'), - external_url: sql`${versionAlias.external_url}`.as('vexternal_url'), - min_update_version: sql`${versionAlias.min_update_version}`.as('vminUpdateVersion'), - r2_path: sql`${versionAlias.r2_path}`.mapWith(versionAlias.r2_path).as('vr2_path'), - } - - // Only include link and comment when needed (for plugin v7.35.0+ with expose_metadata enabled) + id: sql`${versionAlias.id}`.as(`${prefix}id`), + name: sql`${versionAlias.name}`.as(`${prefix}name`), + checksum: sql`${versionAlias.checksum}`.as(`${prefix}checksum`), + session_key: sql`${versionAlias.session_key}`.as(`${prefix}session_key`), + key_id: sql`${versionAlias.key_id}`.as(`${prefix}key_id`), + storage_provider: sql`${versionAlias.storage_provider}`.as(`${prefix}storage_provider`), + external_url: sql`${versionAlias.external_url}`.as(`${prefix}external_url`), + min_update_version: sql`${versionAlias.min_update_version}`.as(`${prefix}minUpdateVersion`), + manifest_count: sql`${versionAlias.manifest_count}`.as(`${prefix}manifest_count`), + r2_path: sql`${versionAlias.r2_path}`.mapWith(versionAlias.r2_path).as(`${prefix}r2_path`), + } + if (includeMetadata) { - versionSelect.link = sql`${versionAlias.link}`.as('vlink') - versionSelect.comment = sql`${versionAlias.comment}`.as('vcomment') + versionSelect.link = sql`${versionAlias.link}`.as(`${prefix}link`) + versionSelect.comment = sql`${versionAlias.comment}`.as(`${prefix}comment`) } + + return versionSelect +} + +function getSchemaUpdatesAlias(includeMetadata = false) { + const { versionAlias, rolloutVersionAlias, channelDevicesAlias, channelAlias } = getAlias() + const versionSelect = getVersionSelect(versionAlias, 'v', includeMetadata) + const rolloutVersionSelect = getVersionSelect(rolloutVersionAlias, 'rv', includeMetadata) const channelSelect = { id: channelAlias.id, name: channelAlias.name, @@ -480,6 +488,13 @@ function getSchemaUpdatesAlias(includeMetadata = false) { electron: channelAlias.electron, allow_device_self_set: channelAlias.allow_device_self_set, public: channelAlias.public, + rollout_version: channelAlias.rollout_version, + rollout_percentage_bps: channelAlias.rollout_percentage_bps, + rollout_enabled: channelAlias.rollout_enabled, + rollout_id: channelAlias.rollout_id, + rollout_paused_at: channelAlias.rollout_paused_at, + rollout_pause_reason: channelAlias.rollout_pause_reason, + rollout_cache_ttl_seconds: channelAlias.rollout_cache_ttl_seconds, } const manifestSelect = sql<{ file_name: string, file_hash: string, s3_path: string }[]>`COALESCE(json_agg( json_build_object( @@ -488,21 +503,24 @@ function getSchemaUpdatesAlias(includeMetadata = false) { 's3_path', ${schema.manifest.s3_path} ) ) FILTER (WHERE ${schema.manifest.file_name} IS NOT NULL), '[]'::json)` - return { versionSelect, channelDevicesAlias, channelAlias, channelSelect, manifestSelect, versionAlias } + return { versionSelect, rolloutVersionSelect, channelDevicesAlias, channelAlias, channelSelect, manifestSelect, versionAlias, rolloutVersionAlias } } function activeChannelVersionJoin( - channelVersionColumn: typeof schema.channels.version, - versionAlias: ReturnType['versionAlias'], + channelVersionColumn: any, + versionAlias: any, + channelAppIdColumn?: any, ) { - // /updates still reaches app_versions through the channel/version PK join. - // The deleted filter is only applied to that single matched row, so it does not widen the hot-path scan. - return and( + const conditions = [ eq(channelVersionColumn, versionAlias.id), or(eq(versionAlias.deleted, false), eq(versionAlias.name, 'builtin')), - ) -} + ] + if (channelAppIdColumn) + conditions.push(eq(versionAlias.app_id, channelAppIdColumn)) + + return and(...conditions) +} export function requestInfosChannelDevicePostgres( c: Context, app_id: string, @@ -589,6 +607,144 @@ export function requestInfosChannelPostgres( return channel } + +export function requestManifestEntriesPostgres( + c: Context, + versionId: number, + drizzleClient: ReturnType, +) { + const manifestQuery = drizzleClient + .select({ + file_name: schema.manifest.file_name, + file_hash: schema.manifest.file_hash, + s3_path: schema.manifest.s3_path, + }) + .from(schema.manifest) + .where(eq(schema.manifest.app_version_id, versionId)) + + cloudlog({ requestId: c.get('requestId'), message: 'rollout manifest Query:', manifestQuery: manifestQuery.toSQL() }) + return manifestQuery +} + +export function requestInfosChannelDevicePostgresRollout( + c: Context, + app_id: string, + device_id: string, + drizzleClient: ReturnType, + includeMetadata = false, +) { + const { versionSelect, rolloutVersionSelect, channelDevicesAlias, channelAlias, channelSelect, versionAlias, rolloutVersionAlias } = getSchemaUpdatesAlias(includeMetadata) + const channelDevice = drizzleClient + .select({ + channel_devices: { + device_id: channelDevicesAlias.device_id, + app_id: sql`${channelDevicesAlias.app_id}`.as('cd_app_id'), + }, + version: versionSelect, + rolloutVersion: rolloutVersionSelect, + channels: channelSelect, + }) + .from(channelDevicesAlias) + .innerJoin(channelAlias, eq(channelDevicesAlias.channel_id, channelAlias.id)) + .innerJoin(versionAlias, activeChannelVersionJoin(channelAlias.version, versionAlias)) + .leftJoin(rolloutVersionAlias, activeChannelVersionJoin(channelAlias.rollout_version, rolloutVersionAlias, channelAlias.app_id)) + .where(and(eq(channelDevicesAlias.device_id, device_id), eq(channelDevicesAlias.app_id, app_id))) + .limit(1) + + cloudlog({ requestId: c.get('requestId'), message: 'channelDevice rollout Query:', channelDeviceQuery: channelDevice.toSQL() }) + return channelDevice.then(data => data.at(0)) +} + +export function requestInfosChannelPostgresRollout( + c: Context, + platform: string, + app_id: string, + defaultChannel: string, + drizzleClient: ReturnType, + includeMetadata = false, +) { + const { versionSelect, rolloutVersionSelect, channelAlias, channelSelect, versionAlias, rolloutVersionAlias } = getSchemaUpdatesAlias(includeMetadata) + const platformQuery = platform === 'android' ? channelAlias.android : platform === 'electron' ? channelAlias.electron : channelAlias.ios + const channelQuery = drizzleClient + .select({ + version: versionSelect, + rolloutVersion: rolloutVersionSelect, + channels: channelSelect, + }) + .from(channelAlias) + .innerJoin(versionAlias, activeChannelVersionJoin(channelAlias.version, versionAlias)) + .leftJoin(rolloutVersionAlias, activeChannelVersionJoin(channelAlias.rollout_version, rolloutVersionAlias, channelAlias.app_id)) + .where( + !defaultChannel + ? and( + eq(channelAlias.public, true), + eq(channelAlias.app_id, app_id), + eq(platformQuery, true), + ) + : and( + eq(channelAlias.app_id, app_id), + eq(channelAlias.name, defaultChannel), + eq(platformQuery, true), + or( + eq(channelAlias.public, true), + eq(channelAlias.allow_device_self_set, true), + ), + ), + ) + .limit(1) + + cloudlog({ requestId: c.get('requestId'), message: 'channel rollout Query:', channelQuery: channelQuery.toSQL() }) + return channelQuery.then(data => data.at(0)) +} + +async function resolveRolloutChannelDataPostgres( + c: Context, + channelData: any, + appId: string, + deviceId: string, + currentVersionName: string, + drizzleClient: ReturnType, + includeManifest: boolean, +) { + if (!channelData) + return channelData + + const stableVersion = channelData.version + const rolloutVersion = channelData.rolloutVersion + let selectedVersion = stableVersion + + if (rolloutVersion?.id && channelData.channels?.rollout_version) { + const decision = await getRolloutDecision(c, { + appId, + channelId: channelData.channels.id, + currentVersionName, + deviceId, + rolloutCacheTtlSeconds: channelData.channels.rollout_cache_ttl_seconds, + rolloutEnabled: channelData.channels.rollout_enabled, + rolloutId: channelData.channels.rollout_id, + rolloutPausedAt: channelData.channels.rollout_paused_at, + rolloutPercentageBps: channelData.channels.rollout_percentage_bps, + rolloutVersionId: rolloutVersion.id, + rolloutVersionName: rolloutVersion.name, + }) + + if (decision.selected) + selectedVersion = rolloutVersion + + cloudlog({ requestId: c.get('requestId'), message: 'rollout decision', appId, channelId: channelData.channels.id, selected: decision.selected, reason: decision.reason }) + } + + const manifestEntries = includeManifest && selectedVersion?.manifest_count > 0 + ? await requestManifestEntriesPostgres(c, selectedVersion.id, drizzleClient) + : [] + + return { + ...channelData, + version: selectedVersion, + manifestEntries, + } +} + export function requestInfosPostgres( c: Context, platform: string, @@ -598,22 +754,49 @@ export function requestInfosPostgres( drizzleClient: ReturnType, channelDeviceCount?: number | null, manifestBundleCount?: number | null, + rolloutChannelCount?: number | null, + currentVersionName = '', includeMetadata = false, ) { const shouldQueryChannelOverride = channelDeviceCount === undefined || channelDeviceCount === null ? true : channelDeviceCount > 0 const shouldFetchManifest = manifestBundleCount === undefined || manifestBundleCount === null ? true : manifestBundleCount > 0 + const shouldUseRolloutPath = rolloutChannelCount === undefined || rolloutChannelCount === null ? false : rolloutChannelCount > 0 + + if (!shouldUseRolloutPath) { + const channelDevice = shouldQueryChannelOverride + ? requestInfosChannelDevicePostgres(c, app_id, device_id, drizzleClient, shouldFetchManifest, includeMetadata) + : Promise.resolve(undefined) + .then(() => { + cloudlog({ requestId: c.get('requestId'), message: 'Skipping channel device override query' }) + return null + }) + const channel = requestInfosChannelPostgres(c, platform, app_id, defaultChannel, drizzleClient, shouldFetchManifest, includeMetadata) + + return Promise.all([channelDevice, channel]) + .then(([channelOverride, channelData]) => ({ channelData, channelOverride })) + .catch((e) => { + logPgError(c, 'requestInfosPostgres', e) + throw e + }) + } const channelDevice = shouldQueryChannelOverride - ? requestInfosChannelDevicePostgres(c, app_id, device_id, drizzleClient, shouldFetchManifest, includeMetadata) + ? requestInfosChannelDevicePostgresRollout(c, app_id, device_id, drizzleClient, includeMetadata) : Promise.resolve(undefined) .then(() => { - cloudlog({ requestId: c.get('requestId'), message: 'Skipping channel device override query' }) + cloudlog({ requestId: c.get('requestId'), message: 'Skipping channel device override rollout query' }) return null }) - const channel = requestInfosChannelPostgres(c, platform, app_id, defaultChannel, drizzleClient, shouldFetchManifest, includeMetadata) + const channel = requestInfosChannelPostgresRollout(c, platform, app_id, defaultChannel, drizzleClient, includeMetadata) return Promise.all([channelDevice, channel]) - .then(([channelOverride, channelData]) => ({ channelData, channelOverride })) + .then(async ([channelOverride, channelData]) => { + const resolvedChannelOverride = await resolveRolloutChannelDataPostgres(c, channelOverride, app_id, device_id, currentVersionName, drizzleClient, shouldFetchManifest) + const resolvedChannelData = resolvedChannelOverride + ? channelData + : await resolveRolloutChannelDataPostgres(c, channelData, app_id, device_id, currentVersionName, drizzleClient, shouldFetchManifest) + return { channelOverride: resolvedChannelOverride, channelData: resolvedChannelData } + }) .catch((e) => { logPgError(c, 'requestInfosPostgres', e) throw e @@ -626,6 +809,7 @@ export interface AppOwnerPostgresResult { plan_valid: boolean channel_device_count: number manifest_bundle_count: number + rollout_channel_count: number expose_metadata: boolean allow_device_custom_id: boolean } @@ -648,6 +832,7 @@ export async function getAppOwnerPostgres( plan_valid: planExpression, channel_device_count: schema.apps.channel_device_count, manifest_bundle_count: schema.apps.manifest_bundle_count, + rollout_channel_count: schema.apps.rollout_channel_count, expose_metadata: schema.apps.expose_metadata, allow_device_custom_id: schema.apps.allow_device_custom_id, orgs: { diff --git a/supabase/functions/_backend/utils/postgres_schema.ts b/supabase/functions/_backend/utils/postgres_schema.ts index 4bfb13168f..5d02c1d191 100644 --- a/supabase/functions/_backend/utils/postgres_schema.ts +++ b/supabase/functions/_backend/utils/postgres_schema.ts @@ -1,4 +1,4 @@ -import { bigint, boolean, integer, jsonb, pgEnum, pgTable, primaryKey, serial, text, timestamp, uuid, varchar } from 'drizzle-orm/pg-core' +import { bigint, boolean, integer, jsonb, numeric, pgEnum, pgTable, primaryKey, serial, text, timestamp, uuid, varchar } from 'drizzle-orm/pg-core' // do_not_change @@ -28,6 +28,7 @@ export const apps = pgTable('apps', { retention: bigint('retention', { mode: 'number' }).notNull().default(2592000), channel_device_count: bigint('channel_device_count', { mode: 'number' }).notNull().default(0), manifest_bundle_count: bigint('manifest_bundle_count', { mode: 'number' }).notNull().default(0), + rollout_channel_count: bigint('rollout_channel_count', { mode: 'number' }).notNull().default(0), expose_metadata: boolean('expose_metadata').notNull().default(false), allow_device_custom_id: boolean('allow_device_custom_id').notNull().default(true), need_onboarding: boolean('need_onboarding').notNull().default(false), @@ -55,6 +56,7 @@ export const app_versions = pgTable('app_versions', { r2_path: varchar('r2_path'), link: varchar('link'), comment: varchar('comment'), + manifest_count: integer('manifest_count').notNull().default(0), }) export const manifest = pgTable('manifest', { @@ -86,6 +88,23 @@ export const channels = pgTable('channels', { allow_device: boolean('allow_device').notNull().default(true), allow_dev: boolean('allow_dev').notNull().default(true), allow_prod: boolean('allow_prod').notNull().default(true), + rollout_version: bigint('rollout_version', { mode: 'number' }).references(() => app_versions.id, { onDelete: 'set null' }), + rollout_percentage_bps: integer('rollout_percentage_bps').notNull().default(0), + rollout_enabled: boolean('rollout_enabled').notNull().default(false), + rollout_id: uuid('rollout_id').notNull().defaultRandom(), + rollout_paused_at: timestamp('rollout_paused_at', { withTimezone: true }), + rollout_pause_reason: text('rollout_pause_reason'), + rollout_cache_ttl_seconds: integer('rollout_cache_ttl_seconds').notNull().default(2592000), + auto_pause_enabled: boolean('auto_pause_enabled').notNull().default(false), + auto_pause_window_minutes: integer('auto_pause_window_minutes').notNull().default(60), + auto_pause_failure_rate_bps: integer('auto_pause_failure_rate_bps'), + auto_pause_confidence: numeric('auto_pause_confidence', { precision: 5, scale: 4 }).notNull().default('0.9500'), + auto_pause_min_attempts: integer('auto_pause_min_attempts'), + auto_pause_min_failures: integer('auto_pause_min_failures'), + auto_pause_action: text('auto_pause_action').notNull().default('pause'), + auto_pause_cooldown_minutes: integer('auto_pause_cooldown_minutes').notNull().default(60), + auto_pause_last_triggered_at: timestamp('auto_pause_last_triggered_at', { withTimezone: true }), + auto_pause_last_checked_at: timestamp('auto_pause_last_checked_at', { withTimezone: true }), rbac_id: uuid('rbac_id').notNull(), }) diff --git a/supabase/functions/_backend/utils/rollout.ts b/supabase/functions/_backend/utils/rollout.ts new file mode 100644 index 0000000000..e356a847dc --- /dev/null +++ b/supabase/functions/_backend/utils/rollout.ts @@ -0,0 +1,327 @@ +import type { Context } from 'hono' +import { CacheHelper } from './cache.ts' + +const MAX_BPS = 10000 +const DEFAULT_ROLLOUT_CACHE_TTL_SECONDS = 2592000 + +export type AutoPauseAction = 'pause' | 'rollback' | 'notify' + +export interface RolloutDecisionCachePayload { + selected: boolean + percentage_bps: number + rollout_id: string + rollout_version: number + created_at: string + updated_at: string +} + +export interface RolloutDecisionInput { + appId: string + channelId: number + currentVersionName: string + deviceId: string + rolloutCacheTtlSeconds: number | null | undefined + rolloutEnabled: boolean + rolloutId: string + rolloutPausedAt: Date | string | null + rolloutPercentageBps: number | null | undefined + rolloutVersionId: number + rolloutVersionName: string + cachePayload?: RolloutDecisionCachePayload | null + now?: Date + randomBps?: () => number +} + +export interface RolloutDecisionResult { + payload: RolloutDecisionCachePayload | null + reason: 'already_on_rollout' | 'cached_selected' | 'cached_unselected' | 'disabled' | 'paused' | 'cache_miss' | 'delta_reroll' | 'percentage_zero' + selected: boolean + shouldWriteCache: boolean + ttlSeconds: number +} + +export interface AutoPauseEvaluationInput { + action: AutoPauseAction + confidence: number + cooldownMinutes: number + enabled: boolean + failureRateBps: number | null + failures: number + installs: number + lastTriggeredAt?: Date | string | null + minAttempts?: number | null + minFailures?: number | null + now?: Date +} + +export interface AutoPauseEvaluationResult { + action: AutoPauseAction + attempts: number + failureRateBps: number + lowerBoundBps: number + reason: 'disabled' | 'missing_threshold' | 'cooldown' | 'insufficient_attempts' | 'insufficient_failures' | 'below_threshold' | 'triggered' + shouldTrigger: boolean + thresholdBps: number | null +} + +function clampInteger(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) + return min + return Math.min(Math.max(Math.floor(value), min), max) +} + +export function sanitizeRolloutPercentageBps(value: number | null | undefined): number { + return clampInteger(Number(value ?? 0), 0, MAX_BPS) +} + +export function sanitizeRolloutCacheTtlSeconds(value: number | null | undefined): number { + return clampInteger(Number(value ?? DEFAULT_ROLLOUT_CACHE_TTL_SECONDS), 60, 31536000) +} + +export function randomPercentageBps(): number { + const values = new Uint32Array(1) + crypto.getRandomValues(values) + return Math.floor((values[0] / 0x100000000) * MAX_BPS) +} + +export function getDeltaProbabilityBps(previousBps: number, nextBps: number): number { + const previous = sanitizeRolloutPercentageBps(previousBps) + const next = sanitizeRolloutPercentageBps(nextBps) + if (next <= previous || previous >= MAX_BPS) + return 0 + return Math.ceil(((next - previous) * MAX_BPS) / (MAX_BPS - previous)) +} + +function isMatchingCachedDecision(input: RolloutDecisionInput, cached: RolloutDecisionCachePayload | null | undefined): cached is RolloutDecisionCachePayload { + return Boolean(cached) + && cached!.rollout_id === input.rolloutId + && cached!.rollout_version === input.rolloutVersionId + && typeof cached!.selected === 'boolean' +} + +function buildPayload(input: RolloutDecisionInput, selected: boolean, percentageBps: number): RolloutDecisionCachePayload { + const now = (input.now ?? new Date()).toISOString() + return { + selected, + percentage_bps: sanitizeRolloutPercentageBps(percentageBps), + rollout_id: input.rolloutId, + rollout_version: input.rolloutVersionId, + created_at: now, + updated_at: now, + } +} + +function updatePayload(input: RolloutDecisionInput, cached: RolloutDecisionCachePayload, selected: boolean, percentageBps: number): RolloutDecisionCachePayload { + return { + ...cached, + selected, + percentage_bps: sanitizeRolloutPercentageBps(percentageBps), + updated_at: (input.now ?? new Date()).toISOString(), + } +} + +export function resolveRolloutDecision(input: RolloutDecisionInput): RolloutDecisionResult { + const percentageBps = sanitizeRolloutPercentageBps(input.rolloutPercentageBps) + const ttlSeconds = sanitizeRolloutCacheTtlSeconds(input.rolloutCacheTtlSeconds) + const cached = isMatchingCachedDecision(input, input.cachePayload) ? input.cachePayload : null + + if (input.currentVersionName === input.rolloutVersionName) { + return { + selected: true, + shouldWriteCache: true, + payload: cached?.selected ? updatePayload(input, cached, true, Math.max(cached.percentage_bps, percentageBps)) : buildPayload(input, true, percentageBps), + reason: 'already_on_rollout', + ttlSeconds, + } + } + + if (cached?.selected) { + return { + selected: true, + shouldWriteCache: false, + payload: cached, + reason: 'cached_selected', + ttlSeconds, + } + } + + if (!input.rolloutEnabled) { + return { + selected: false, + shouldWriteCache: false, + payload: cached, + reason: 'disabled', + ttlSeconds, + } + } + + if (input.rolloutPausedAt) { + return { + selected: false, + shouldWriteCache: false, + payload: cached, + reason: 'paused', + ttlSeconds, + } + } + + if (percentageBps <= 0) { + return { + selected: false, + shouldWriteCache: false, + payload: cached, + reason: 'percentage_zero', + ttlSeconds, + } + } + + const randomBps = input.randomBps ?? randomPercentageBps + + if (cached) { + if (percentageBps <= cached.percentage_bps) { + return { + selected: false, + shouldWriteCache: false, + payload: cached, + reason: 'cached_unselected', + ttlSeconds, + } + } + + const selected = randomBps() < getDeltaProbabilityBps(cached.percentage_bps, percentageBps) + return { + selected, + shouldWriteCache: true, + payload: updatePayload(input, cached, selected, percentageBps), + reason: 'delta_reroll', + ttlSeconds, + } + } + + const selected = randomBps() < percentageBps + return { + selected, + shouldWriteCache: true, + payload: buildPayload(input, selected, percentageBps), + reason: 'cache_miss', + ttlSeconds, + } +} + +async function hashDeviceId(deviceId: string): Promise { + const bytes = new TextEncoder().encode(deviceId) + const digest = await crypto.subtle.digest('SHA-256', bytes) + return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('') +} + +export async function getRolloutDecision(c: Context, input: Omit): Promise { + const cache = new CacheHelper(c) + const deviceHash = await hashDeviceId(input.deviceId) + const request = cache.buildRequest('/cache/rollouts/v1', { + app_id: input.appId, + channel_id: String(input.channelId), + device: deviceHash, + rollout_id: input.rolloutId, + }) + const cached = await cache.matchJson(request) + const decision = resolveRolloutDecision({ ...input, cachePayload: cached }) + + if (decision.shouldWriteCache && decision.payload) + await cache.putJson(request, decision.payload, decision.ttlSeconds) + + return decision +} + +function parseDate(value: Date | string | null | undefined): Date | null { + if (!value) + return null + const date = value instanceof Date ? value : new Date(value) + return Number.isNaN(date.getTime()) ? null : date +} + +function normalQuantile(p: number): number { + if (p <= 0 || p >= 1) + throw new Error('p must be between 0 and 1') + + const a = [-39.69683028665376, 220.9460984245205, -275.9285104469687, 138.357751867269, -30.66479806614716, 2.506628277459239] + const b = [-54.47609879822406, 161.5858368580409, -155.6989798598866, 66.80131188771972, -13.28068155288572] + const c = [-0.007784894002430293, -0.3223964580411365, -2.400758277161838, -2.549732539343734, 4.374664141464968, 2.938163982698783] + const d = [0.007784695709041462, 0.3224671290700398, 2.445134137142996, 3.754408661907416] + const plow = 0.02425 + const phigh = 1 - plow + + if (p < plow) { + const q = Math.sqrt(-2 * Math.log(p)) + return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) + / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1) + } + + if (p > phigh) { + const q = Math.sqrt(-2 * Math.log(1 - p)) + return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) + / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1) + } + + const q = p - 0.5 + const r = q * q + return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q + / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1) +} + +export function wilsonLowerBoundBps(failures: number, attempts: number, confidence: number): number { + if (attempts <= 0) + return 0 + + const boundedConfidence = Math.min(Math.max(confidence, 0.0001), 0.9999) + const z = normalQuantile(1 - ((1 - boundedConfidence) / 2)) + const phat = failures / attempts + const z2 = z * z + const denominator = 1 + z2 / attempts + const centre = phat + z2 / (2 * attempts) + const margin = z * Math.sqrt((phat * (1 - phat) + z2 / (4 * attempts)) / attempts) + return sanitizeRolloutPercentageBps(((centre - margin) / denominator) * MAX_BPS) +} + +export function evaluateAutoPausePolicy(input: AutoPauseEvaluationInput): AutoPauseEvaluationResult { + const attempts = Math.max(0, Math.floor(input.installs + input.failures)) + const failures = Math.max(0, Math.floor(input.failures)) + const failureRateBps = attempts > 0 ? sanitizeRolloutPercentageBps((failures / attempts) * MAX_BPS) : 0 + const thresholdBps = input.failureRateBps == null ? null : sanitizeRolloutPercentageBps(input.failureRateBps) + const lowerBoundBps = wilsonLowerBoundBps(failures, attempts, input.confidence) + + const base = { + action: input.action, + attempts, + failureRateBps, + lowerBoundBps, + thresholdBps, + } + + if (!input.enabled) { + return { ...base, shouldTrigger: false, reason: 'disabled' } + } + + if (thresholdBps === null) { + return { ...base, shouldTrigger: false, reason: 'missing_threshold' } + } + + const lastTriggeredAt = parseDate(input.lastTriggeredAt) + const cooldownMs = Math.max(0, input.cooldownMinutes) * 60 * 1000 + if (lastTriggeredAt && cooldownMs > 0 && lastTriggeredAt.getTime() + cooldownMs > (input.now ?? new Date()).getTime()) { + return { ...base, shouldTrigger: false, reason: 'cooldown' } + } + + if (input.minAttempts != null && attempts < input.minAttempts) { + return { ...base, shouldTrigger: false, reason: 'insufficient_attempts' } + } + + if (input.minFailures != null && failures < input.minFailures) { + return { ...base, shouldTrigger: false, reason: 'insufficient_failures' } + } + + if (lowerBoundBps < thresholdBps) { + return { ...base, shouldTrigger: false, reason: 'below_threshold' } + } + + return { ...base, shouldTrigger: true, reason: 'triggered' } +} diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index f301a5f2ac..d370b292d7 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -266,6 +266,7 @@ export type Database = { ios_store_url: string | null last_version: string | null manifest_bundle_count: number + rollout_channel_count: number name: string | null need_onboarding: boolean owner_org: string @@ -291,6 +292,7 @@ export type Database = { ios_store_url?: string | null last_version?: string | null manifest_bundle_count?: number + rollout_channel_count?: number name?: string | null need_onboarding?: boolean owner_org: string @@ -316,6 +318,7 @@ export type Database = { ios_store_url?: string | null last_version?: string | null manifest_bundle_count?: number + rollout_channel_count?: number name?: string | null need_onboarding?: boolean owner_org?: string @@ -704,6 +707,23 @@ export type Database = { name: string owner_org: string public: boolean + auto_pause_action: string + auto_pause_confidence: number + auto_pause_cooldown_minutes: number + auto_pause_enabled: boolean + auto_pause_failure_rate_bps: number | null + auto_pause_last_checked_at: string | null + auto_pause_last_triggered_at: string | null + auto_pause_min_attempts: number | null + auto_pause_min_failures: number | null + auto_pause_window_minutes: number + rollout_cache_ttl_seconds: number + rollout_enabled: boolean + rollout_id: string + rollout_pause_reason: string | null + rollout_paused_at: string | null + rollout_percentage_bps: number + rollout_version: number | null rbac_id: string updated_at: string version: number @@ -726,6 +746,23 @@ export type Database = { name: string owner_org: string public?: boolean + auto_pause_action?: string + auto_pause_confidence?: number + auto_pause_cooldown_minutes?: number + auto_pause_enabled?: boolean + auto_pause_failure_rate_bps?: number | null + auto_pause_last_checked_at?: string | null + auto_pause_last_triggered_at?: string | null + auto_pause_min_attempts?: number | null + auto_pause_min_failures?: number | null + auto_pause_window_minutes?: number + rollout_cache_ttl_seconds?: number + rollout_enabled?: boolean + rollout_id?: string + rollout_pause_reason?: string | null + rollout_paused_at?: string | null + rollout_percentage_bps?: number + rollout_version?: number | null rbac_id?: string updated_at?: string version: number @@ -748,6 +785,23 @@ export type Database = { name?: string owner_org?: string public?: boolean + auto_pause_action?: string + auto_pause_confidence?: number + auto_pause_cooldown_minutes?: number + auto_pause_enabled?: boolean + auto_pause_failure_rate_bps?: number | null + auto_pause_last_checked_at?: string | null + auto_pause_last_triggered_at?: string | null + auto_pause_min_attempts?: number | null + auto_pause_min_failures?: number | null + auto_pause_window_minutes?: number + rollout_cache_ttl_seconds?: number + rollout_enabled?: boolean + rollout_id?: string + rollout_pause_reason?: string | null + rollout_paused_at?: string | null + rollout_percentage_bps?: number + rollout_version?: number | null rbac_id?: string updated_at?: string version?: number @@ -760,6 +814,13 @@ export type Database = { referencedRelation: "apps" referencedColumns: ["app_id"] }, + { + foreignKeyName: "channels_rollout_version_fkey" + columns: ["rollout_version"] + isOneToOne: false + referencedRelation: "app_versions" + referencedColumns: ["id"] + }, { foreignKeyName: "channels_version_fkey" columns: ["version"] @@ -3225,6 +3286,7 @@ export type Database = { ios_store_url: string | null last_version: string | null manifest_bundle_count: number + rollout_channel_count: number name: string | null need_onboarding: boolean owner_org: string diff --git a/supabase/functions/_backend/utils/update.ts b/supabase/functions/_backend/utils/update.ts index dd875c607b..5f80bf442c 100644 --- a/supabase/functions/_backend/utils/update.ts +++ b/supabase/functions/_backend/utils/update.ts @@ -141,6 +141,7 @@ export async function updateWithPG( await setAppStatus(c, app_id, 'cloud', appOwner.allow_device_custom_id) const channelDeviceCount = appOwner.channel_device_count ?? 0 const manifestBundleCount = appOwner.manifest_bundle_count ?? 0 + const rolloutChannelCount = appOwner.rollout_channel_count ?? 0 const bypassChannelOverrides = channelDeviceCount <= 0 const pluginVersion = parse(plugin_version) // v5 is deprecated if < 5.10.0, v6 is deprecated if < 6.25.0, v7 is deprecated if < 7.25.0 @@ -154,6 +155,7 @@ export async function updateWithPG( channelDeviceCount, bypassChannelOverrides, manifestBundleCount, + rolloutChannelCount, fetchManifestEntries, }) if (body.version_build === 'unknown') { @@ -203,7 +205,7 @@ export async function updateWithPG( // Only query link/comment if plugin supports it (v5.35.0+, v6.35.0+, v7.35.0+, v8.35.0+) AND app has expose_metadata enabled const needsMetadata = appOwner.expose_metadata && !isDeprecatedPluginVersion(pluginVersion, '5.35.0', '6.35.0', '7.35.0', '8.35.0') - const requestedInto = await requestInfosPostgres(c, platform, app_id, device_id, defaultChannel, drizzleClient, channelDeviceCount, manifestBundleCount, needsMetadata) + const requestedInto = await requestInfosPostgres(c, platform, app_id, device_id, defaultChannel, drizzleClient, channelDeviceCount, manifestBundleCount, rolloutChannelCount, version_name, needsMetadata) const { channelOverride } = requestedInto let { channelData } = requestedInto cloudlog({ requestId: c.get('requestId'), message: `channelData exists ? ${channelData !== undefined}, channelOverride exists ? ${channelOverride !== undefined}` }) diff --git a/supabase/functions/triggers/index.ts b/supabase/functions/triggers/index.ts index 91980b3cef..1f6a7f7dc9 100644 --- a/supabase/functions/triggers/index.ts +++ b/supabase/functions/triggers/index.ts @@ -3,6 +3,7 @@ import { app as cron_clean_orphan_images } from '../_backend/triggers/cron_clean import { app as cron_clear_versions } from '../_backend/triggers/cron_clear_versions.ts' import { app as cron_email } from '../_backend/triggers/cron_email.ts' import { app as cron_reconcile_build_status } from '../_backend/triggers/cron_reconcile_build_status.ts' +import { app as cron_rollout_auto_pause } from '../_backend/triggers/cron_rollout_auto_pause.ts' import { app as cron_stat_app } from '../_backend/triggers/cron_stat_app.ts' import { app as cron_stat_org } from '../_backend/triggers/cron_stat_org.ts' import { app as cron_sync_sub } from '../_backend/triggers/cron_sync_sub.ts' @@ -54,6 +55,7 @@ appGlobal.route('/cron_sync_sub', cron_sync_sub) appGlobal.route('/cron_clear_versions', cron_clear_versions) appGlobal.route('/cron_clean_orphan_images', cron_clean_orphan_images) appGlobal.route('/cron_reconcile_build_status', cron_reconcile_build_status) +appGlobal.route('/cron_rollout_auto_pause', cron_rollout_auto_pause) appGlobal.route('/credit_usage_alerts', credit_usage_alerts) appGlobal.route('/on_organization_delete', on_organization_delete) appGlobal.route('/on_deploy_history_create', on_deploy_history_create) diff --git a/supabase/migrations/20260506154832_random_sticky_rollouts.sql b/supabase/migrations/20260506154832_random_sticky_rollouts.sql new file mode 100644 index 0000000000..299d9b5a94 --- /dev/null +++ b/supabase/migrations/20260506154832_random_sticky_rollouts.sql @@ -0,0 +1,312 @@ +ALTER TABLE "public"."apps" +ADD COLUMN "rollout_channel_count" bigint NOT NULL DEFAULT 0; + +ALTER TABLE "public"."channels" +ADD COLUMN "rollout_version" bigint, +ADD COLUMN "rollout_percentage_bps" integer NOT NULL DEFAULT 0, +ADD COLUMN "rollout_enabled" boolean NOT NULL DEFAULT false, +ADD COLUMN "rollout_id" uuid NOT NULL DEFAULT gen_random_uuid(), +ADD COLUMN "rollout_paused_at" timestamp with time zone, +ADD COLUMN "rollout_pause_reason" text, +ADD COLUMN "rollout_cache_ttl_seconds" integer NOT NULL DEFAULT 2592000, +ADD COLUMN "auto_pause_enabled" boolean NOT NULL DEFAULT false, +ADD COLUMN "auto_pause_window_minutes" integer NOT NULL DEFAULT 60, +ADD COLUMN "auto_pause_failure_rate_bps" integer, +ADD COLUMN "auto_pause_confidence" numeric(5, 4) NOT NULL DEFAULT 0.9500, +ADD COLUMN "auto_pause_min_attempts" integer, +ADD COLUMN "auto_pause_min_failures" integer, +ADD COLUMN "auto_pause_action" text NOT NULL DEFAULT 'pause', +ADD COLUMN "auto_pause_cooldown_minutes" integer NOT NULL DEFAULT 60, +ADD COLUMN "auto_pause_last_triggered_at" timestamp with time zone, +ADD COLUMN "auto_pause_last_checked_at" timestamp with time zone; + +ALTER TABLE "public"."channels" +ADD CONSTRAINT "channels_rollout_version_fkey" +FOREIGN KEY ("rollout_version") +REFERENCES "public"."app_versions"("id") +ON DELETE SET NULL; + +ALTER TABLE "public"."channels" +ADD CONSTRAINT "channels_rollout_percentage_bps_check" +CHECK ("rollout_percentage_bps" >= 0 AND "rollout_percentage_bps" <= 10000); + +ALTER TABLE "public"."channels" +ADD CONSTRAINT "channels_rollout_cache_ttl_seconds_check" +CHECK ("rollout_cache_ttl_seconds" >= 60 AND "rollout_cache_ttl_seconds" <= 31536000); + +ALTER TABLE "public"."channels" +ADD CONSTRAINT "channels_auto_pause_window_minutes_check" +CHECK ("auto_pause_window_minutes" > 0 AND "auto_pause_window_minutes" <= 10080); + +ALTER TABLE "public"."channels" +ADD CONSTRAINT "channels_auto_pause_failure_rate_bps_check" +CHECK ("auto_pause_failure_rate_bps" IS NULL OR ("auto_pause_failure_rate_bps" >= 0 AND "auto_pause_failure_rate_bps" <= 10000)); + +ALTER TABLE "public"."channels" +ADD CONSTRAINT "channels_auto_pause_confidence_check" +CHECK ("auto_pause_confidence" > 0 AND "auto_pause_confidence" < 1); + +ALTER TABLE "public"."channels" +ADD CONSTRAINT "channels_auto_pause_min_attempts_check" +CHECK ("auto_pause_min_attempts" IS NULL OR "auto_pause_min_attempts" >= 0); + +ALTER TABLE "public"."channels" +ADD CONSTRAINT "channels_auto_pause_min_failures_check" +CHECK ("auto_pause_min_failures" IS NULL OR "auto_pause_min_failures" >= 0); + +ALTER TABLE "public"."channels" +ADD CONSTRAINT "channels_auto_pause_action_check" +CHECK ("auto_pause_action" IN ('pause', 'rollback', 'notify')); + +ALTER TABLE "public"."channels" +ADD CONSTRAINT "channels_auto_pause_cooldown_minutes_check" +CHECK ("auto_pause_cooldown_minutes" >= 0 AND "auto_pause_cooldown_minutes" <= 10080); + +CREATE INDEX "idx_channels_rollout_version" +ON "public"."channels" ("rollout_version") +WHERE "rollout_version" IS NOT NULL; + +CREATE INDEX "idx_channels_active_rollouts" +ON "public"."channels" ("app_id", "rollout_enabled", "rollout_version") +WHERE "rollout_enabled" = true AND "rollout_version" IS NOT NULL; + +CREATE OR REPLACE FUNCTION "public"."refresh_app_rollout_channel_count_for_app"("p_app_id" character varying) +RETURNS void +LANGUAGE "plpgsql" +SECURITY DEFINER +SET "search_path" TO '' +AS $$ +BEGIN + IF "p_app_id" IS NULL THEN + RETURN; + END IF; + + UPDATE "public"."apps" AS a + SET + "rollout_channel_count" = ( + SELECT count(*)::bigint + FROM "public"."channels" AS c + WHERE c."app_id" = "p_app_id" + AND c."rollout_enabled" = true + AND c."rollout_version" IS NOT NULL + ), + "updated_at" = now() + WHERE a."app_id" = "p_app_id"; +END; +$$; + +ALTER FUNCTION "public"."refresh_app_rollout_channel_count_for_app"("p_app_id" character varying) OWNER TO "postgres"; +REVOKE ALL ON FUNCTION "public"."refresh_app_rollout_channel_count_for_app"("p_app_id" character varying) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."refresh_app_rollout_channel_count_for_app"("p_app_id" character varying) TO "service_role"; + +CREATE OR REPLACE FUNCTION "public"."refresh_app_rollout_channel_count"() +RETURNS trigger +LANGUAGE "plpgsql" +SECURITY DEFINER +SET "search_path" TO '' +AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + PERFORM "public"."refresh_app_rollout_channel_count_for_app"(NEW."app_id"); + RETURN NEW; + ELSIF TG_OP = 'DELETE' THEN + PERFORM "public"."refresh_app_rollout_channel_count_for_app"(OLD."app_id"); + RETURN OLD; + END IF; + + PERFORM "public"."refresh_app_rollout_channel_count_for_app"(NEW."app_id"); + IF OLD."app_id" IS DISTINCT FROM NEW."app_id" THEN + PERFORM "public"."refresh_app_rollout_channel_count_for_app"(OLD."app_id"); + END IF; + + RETURN NEW; +END; +$$; + +ALTER FUNCTION "public"."refresh_app_rollout_channel_count"() OWNER TO "postgres"; +REVOKE ALL ON FUNCTION "public"."refresh_app_rollout_channel_count"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."refresh_app_rollout_channel_count"() TO "service_role"; + +CREATE OR REPLACE FUNCTION "public"."refresh_channel_rollout_id"() +RETURNS trigger +LANGUAGE "plpgsql" +SECURITY DEFINER +SET "search_path" TO '' +AS $$ +BEGIN + IF NEW."rollout_version" IS DISTINCT FROM OLD."rollout_version" THEN + NEW."rollout_id" = gen_random_uuid(); + NEW."rollout_paused_at" = NULL; + NEW."rollout_pause_reason" = NULL; + NEW."auto_pause_last_triggered_at" = NULL; + END IF; + + RETURN NEW; +END; +$$; + +ALTER FUNCTION "public"."refresh_channel_rollout_id"() OWNER TO "postgres"; +REVOKE ALL ON FUNCTION "public"."refresh_channel_rollout_id"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."refresh_channel_rollout_id"() TO "service_role"; + +DROP TRIGGER IF EXISTS "refresh_channel_rollout_id" ON "public"."channels"; +CREATE TRIGGER "refresh_channel_rollout_id" +BEFORE UPDATE OF "rollout_version" ON "public"."channels" +FOR EACH ROW +EXECUTE FUNCTION "public"."refresh_channel_rollout_id"(); + +DROP TRIGGER IF EXISTS "refresh_app_rollout_channel_count" ON "public"."channels"; +CREATE TRIGGER "refresh_app_rollout_channel_count" +AFTER INSERT OR UPDATE OF "app_id", "rollout_enabled", "rollout_version" OR DELETE ON "public"."channels" +FOR EACH ROW +EXECUTE FUNCTION "public"."refresh_app_rollout_channel_count"(); + +UPDATE "public"."apps" AS a +SET "rollout_channel_count" = rollout_counts.rollout_count +FROM ( + SELECT + c."app_id", + count(*)::bigint AS rollout_count + FROM "public"."channels" AS c + WHERE c."rollout_enabled" = true + AND c."rollout_version" IS NOT NULL + GROUP BY c."app_id" +) AS rollout_counts +WHERE rollout_counts."app_id" = a."app_id"; + +CREATE OR REPLACE FUNCTION "public"."update_app_versions_retention"() +RETURNS void +LANGUAGE "plpgsql" +SET "search_path" TO '' +AS $$ +BEGIN + UPDATE "public"."app_versions" + SET "deleted" = true, "updated_at" = NOW() + WHERE "app_versions"."deleted" = false + AND (SELECT "retention" FROM "public"."apps" WHERE "apps"."app_id" = "app_versions"."app_id") >= 0 + AND (SELECT "retention" FROM "public"."apps" WHERE "apps"."app_id" = "app_versions"."app_id") < 63113904 + AND "app_versions"."created_at" < ( + SELECT NOW() - make_interval(secs => "apps"."retention") + FROM "public"."apps" + WHERE "apps"."app_id" = "app_versions"."app_id" + ) + AND NOT EXISTS ( + SELECT 1 + FROM "public"."channels" + WHERE "channels"."app_id" = "app_versions"."app_id" + AND ( + "channels"."version" = "app_versions"."id" + OR "channels"."rollout_version" = "app_versions"."id" + ) + ); +END; +$$; + +ALTER FUNCTION "public"."update_app_versions_retention"() OWNER TO "postgres"; + +CREATE OR REPLACE FUNCTION "public"."delete_old_deleted_versions"() +RETURNS "void" +LANGUAGE "plpgsql" +SECURITY DEFINER +SET "search_path" TO '' +AS $$ +DECLARE + deleted_count bigint; +BEGIN + DELETE FROM "public"."app_versions" + WHERE "deleted" = true + AND "updated_at" < NOW() - INTERVAL '1 year' + AND NOT EXISTS ( + SELECT 1 + FROM "public"."channels" + WHERE "channels"."version" = "app_versions"."id" + OR "channels"."rollout_version" = "app_versions"."id" + ); + + GET DIAGNOSTICS deleted_count = ROW_COUNT; + + IF deleted_count > 0 THEN + RAISE NOTICE 'delete_old_deleted_versions: permanently deleted % app versions', deleted_count; + END IF; +END; +$$; + +ALTER FUNCTION "public"."delete_old_deleted_versions"() OWNER TO "postgres"; +REVOKE EXECUTE ON FUNCTION "public"."delete_old_deleted_versions"() FROM public; +GRANT EXECUTE ON FUNCTION "public"."delete_old_deleted_versions"() TO service_role; + +SELECT pgmq.create('cron_rollout_auto_pause'); + +INSERT INTO "public"."cron_tasks" ( + "name", + "description", + "task_type", + "target", + "batch_size", + "second_interval", + "minute_interval", + "hour_interval", + "run_at_hour", + "run_at_minute", + "run_at_second", + "run_on_dow", + "run_on_day" +) VALUES ( + 'rollout_auto_pause', + 'Queue progressive rollout auto-pause evaluation', + 'queue', + 'cron_rollout_auto_pause', + null, + null, + 5, + null, + null, + null, + 0, + null, + null +) +ON CONFLICT ("name") DO UPDATE SET + "description" = excluded."description", + "task_type" = excluded."task_type", + "target" = excluded."target", + "minute_interval" = excluded."minute_interval", + "run_at_second" = excluded."run_at_second", + "updated_at" = NOW(); + +INSERT INTO "public"."cron_tasks" ( + "name", + "description", + "task_type", + "target", + "batch_size", + "second_interval", + "minute_interval", + "hour_interval", + "run_at_hour", + "run_at_minute", + "run_at_second", + "run_on_dow", + "run_on_day" +) VALUES ( + 'rollout_auto_pause_queue', + 'Process progressive rollout auto-pause evaluation queue', + 'function_queue', + '["cron_rollout_auto_pause"]', + null, + null, + 1, + null, + null, + null, + 0, + null, + null +) +ON CONFLICT ("name") DO UPDATE SET + "description" = excluded."description", + "task_type" = excluded."task_type", + "target" = excluded."target", + "minute_interval" = excluded."minute_interval", + "run_at_second" = excluded."run_at_second", + "updated_at" = NOW(); diff --git a/tests/audit-logs.test.ts b/tests/audit-logs.test.ts index 9ae2556103..2015fbec6e 100644 --- a/tests/audit-logs.test.ts +++ b/tests/audit-logs.test.ts @@ -577,7 +577,7 @@ describe('audit logs for app_versions via API key', () => { expect(safe.success).toBe(true) if (safe.success) { - // Find the audit log for our soft-deleted version (look for 'deleted' in changed_fields) + // Find the audit log for our soft-deleted version:app_versions!channels_version_fkey(look for 'deleted' in changed_fields) const deleteAuditLog = safe.data.data.find( log => log.record_id === versionIdToDelete.toString() && log.changed_fields?.includes('deleted'), diff --git a/tests/channel_self.test.ts b/tests/channel_self.test.ts index 185d27a3ef..ddf6c39eb0 100644 --- a/tests/channel_self.test.ts +++ b/tests/channel_self.test.ts @@ -1110,7 +1110,7 @@ it('saves default_channel when provided', async () => { await getSupabaseClient().from('devices').delete().eq('device_id', uuid).eq('app_id', APPNAME) }) -describe('[POST] /channel_self - new plugin version (>= 7.34.0) behavior', () => { +describe('[POST] /channel_self - new plugin version:app_versions!channels_version_fkey(>= 7.34.0) behavior', () => { it('should validate and return success without storing in channel_devices for new plugin versions', async () => { const data = getUniqueBaseData(APPNAME) data.plugin_version = '7.34.0' // New version @@ -1187,7 +1187,7 @@ describe('[POST] /channel_self - new plugin version (>= 7.34.0) behavior', () => .eq('app_id', APPNAME) try { - // First, set channel with old version (stores in channel_devices) + // First, set channel with old version:app_versions!channels_version_fkey(stores in channel_devices) data.plugin_version = '7.33.0' data.channel = 'beta' // Use non-default channel @@ -1204,7 +1204,7 @@ describe('[POST] /channel_self - new plugin version (>= 7.34.0) behavior', () => expect(oldChannelDevice).toBeTruthy() - // Then, set channel with new version (should clean up old entry) + // Then, set channel with new version:app_versions!channels_version_fkey(should clean up old entry) data.plugin_version = '7.34.0' data.channel = 'development' @@ -1242,7 +1242,7 @@ describe('[POST] /channel_self - new plugin version (>= 7.34.0) behavior', () => }) }) -describe('[PUT] /channel_self - new plugin version (>= 7.34.0) behavior', () => { +describe('[PUT] /channel_self - new plugin version:app_versions!channels_version_fkey(>= 7.34.0) behavior', () => { it('should return channel from request body for new plugin versions', async () => { const data = getUniqueBaseData(APPNAME) data.plugin_version = '7.34.0' @@ -1273,7 +1273,7 @@ describe('[PUT] /channel_self - new plugin version (>= 7.34.0) behavior', () => }) }) -describe('[DELETE] /channel_self - new plugin version (>= 7.34.0) behavior', () => { +describe('[DELETE] /channel_self - new plugin version:app_versions!channels_version_fkey(>= 7.34.0) behavior', () => { it('should return success and clean up old channel_devices entries for new plugin versions', async () => { const deviceId = randomUUID() const data = getUniqueBaseData(APPNAME) diff --git a/tests/cli-channel.test.ts b/tests/cli-channel.test.ts index f1d35b33b1..0c58a2163e 100644 --- a/tests/cli-channel.test.ts +++ b/tests/cli-channel.test.ts @@ -156,7 +156,7 @@ describe('tests CLI channel commands', () => { // Verify in database const { data, error } = await getSupabaseClient() .from('channels') - .select('id, version (id, name)') + .select('id, version:app_versions!channels_version_fkey(id, name)') .eq('name', channelName) .eq('app_id', APPNAME) .single() @@ -406,7 +406,7 @@ describe('tests CLI channel commands', () => { // Verify in database const { data, error } = await getSupabaseClient() .from('channels') - .select('version (name)') + .select('version:app_versions!channels_version_fkey(name)') .eq('name', channelName) .eq('app_id', APPNAME) .single() diff --git a/tests/cli-min-version.test.ts b/tests/cli-min-version.test.ts index c3eb98fc24..370a47e026 100644 --- a/tests/cli-min-version.test.ts +++ b/tests/cli-min-version.test.ts @@ -135,7 +135,7 @@ describe('tests min version', () => { await writeBundleContent(APPNAME, `auto-min-${semverDefault}`) - // Upload with auto-min-update-version (needs metadata check enabled) + // Upload with auto-min-update-version:app_versions!channels_version_fkey(needs metadata check enabled) const result0 = await retryUpload(() => uploadBundleSDK(APPNAME, semverDefault, channelName, { ignoreCompatibilityCheck: false, packageJsonPaths: packageJsonPath, diff --git a/tests/cli-sdk-utils.ts b/tests/cli-sdk-utils.ts index 0ca541cdef..16b69e5e45 100644 --- a/tests/cli-sdk-utils.ts +++ b/tests/cli-sdk-utils.ts @@ -276,7 +276,7 @@ async function getAppsForApiKey(apikey: string, allowedModes: Database['public'] async function getChannelVersionRecord(appId: string, channelId: string) { const { data } = await getSupabaseClient() .from('channels') - .select('version ( id, checksum, min_update_version, name, native_packages )') + .select('version:app_versions!channels_version_fkey( id, checksum, min_update_version, name, native_packages )') .eq('app_id', appId) .eq('name', channelId) .maybeSingle() diff --git a/tests/expose-metadata.test.ts b/tests/expose-metadata.test.ts index c8ab8c8a68..0193624fbb 100644 --- a/tests/expose-metadata.test.ts +++ b/tests/expose-metadata.test.ts @@ -155,7 +155,7 @@ describe('expose_metadata feature', () => { }) .eq('customer_id', STRIPE_INFO_CUSTOMER_ID) - // Add link and comment to the default version (1.0.0) + // Add link and comment to the default version:app_versions!channels_version_fkey(1.0.0) const { data, error } = await supabase .from('app_versions') .update({ diff --git a/tests/rollout.test.ts b/tests/rollout.test.ts new file mode 100644 index 0000000000..622425c4ae --- /dev/null +++ b/tests/rollout.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest' +import { evaluateAutoPausePolicy, getDeltaProbabilityBps, resolveRolloutDecision } from '../supabase/functions/_backend/utils/rollout.ts' + +const baseDecision = { + appId: 'com.test.rollout', + channelId: 1, + currentVersionName: '1.0.0', + deviceId: 'device-a', + rolloutCacheTtlSeconds: 3600, + rolloutEnabled: true, + rolloutId: '11111111-1111-4111-8111-111111111111', + rolloutPausedAt: null, + rolloutPercentageBps: 0, + rolloutVersionId: 10, + rolloutVersionName: '1.1.0', + now: new Date('2026-05-06T12:00:00.000Z'), +} + +describe('rollout decisions', () => { + it('returns stable at 0 percent', () => { + const decision = resolveRolloutDecision({ + ...baseDecision, + rolloutPercentageBps: 0, + randomBps: () => 0, + }) + + expect(decision.selected).toBe(false) + expect(decision.reason).toBe('percentage_zero') + expect(decision.shouldWriteCache).toBe(false) + }) + + it('selects rollout at 100 percent', () => { + const decision = resolveRolloutDecision({ + ...baseDecision, + rolloutPercentageBps: 10000, + randomBps: () => 9999, + }) + + expect(decision.selected).toBe(true) + expect(decision.reason).toBe('cache_miss') + expect(decision.payload?.selected).toBe(true) + }) + + it('keeps cached unselected devices stable when percentage is unchanged', () => { + const decision = resolveRolloutDecision({ + ...baseDecision, + rolloutPercentageBps: 2500, + cachePayload: { + selected: false, + percentage_bps: 2500, + rollout_id: baseDecision.rolloutId, + rollout_version: baseDecision.rolloutVersionId, + created_at: '2026-05-06T11:00:00.000Z', + updated_at: '2026-05-06T11:00:00.000Z', + }, + randomBps: () => 0, + }) + + expect(decision.selected).toBe(false) + expect(decision.reason).toBe('cached_unselected') + expect(decision.shouldWriteCache).toBe(false) + }) + + it('re-rolls only the delta probability after percentage increases', () => { + expect(getDeltaProbabilityBps(2000, 5000)).toBe(3750) + + const selected = resolveRolloutDecision({ + ...baseDecision, + rolloutPercentageBps: 5000, + cachePayload: { + selected: false, + percentage_bps: 2000, + rollout_id: baseDecision.rolloutId, + rollout_version: baseDecision.rolloutVersionId, + created_at: '2026-05-06T11:00:00.000Z', + updated_at: '2026-05-06T11:00:00.000Z', + }, + randomBps: () => 3749, + }) + + const notSelected = resolveRolloutDecision({ + ...baseDecision, + rolloutPercentageBps: 5000, + cachePayload: { + selected: false, + percentage_bps: 2000, + rollout_id: baseDecision.rolloutId, + rollout_version: baseDecision.rolloutVersionId, + created_at: '2026-05-06T11:00:00.000Z', + updated_at: '2026-05-06T11:00:00.000Z', + }, + randomBps: () => 3750, + }) + + expect(selected.selected).toBe(true) + expect(notSelected.selected).toBe(false) + }) + + it('keeps devices already on rollout on rollout when paused and cache is missing', () => { + const decision = resolveRolloutDecision({ + ...baseDecision, + currentVersionName: '1.1.0', + rolloutEnabled: false, + rolloutPausedAt: '2026-05-06T11:30:00.000Z', + rolloutPercentageBps: 0, + }) + + expect(decision.selected).toBe(true) + expect(decision.reason).toBe('already_on_rollout') + expect(decision.payload?.selected).toBe(true) + }) + + it('does not expose new devices while paused', () => { + const decision = resolveRolloutDecision({ + ...baseDecision, + rolloutPausedAt: '2026-05-06T11:30:00.000Z', + rolloutPercentageBps: 10000, + randomBps: () => 0, + }) + + expect(decision.selected).toBe(false) + expect(decision.reason).toBe('paused') + }) +}) + +describe('rollout auto-pause policy', () => { + it('respects disabled state', () => { + const result = evaluateAutoPausePolicy({ + action: 'pause', + confidence: 0.95, + cooldownMinutes: 60, + enabled: false, + failureRateBps: 100, + failures: 100, + installs: 0, + }) + + expect(result.shouldTrigger).toBe(false) + expect(result.reason).toBe('disabled') + }) + + it('respects configurable minimums and cooldown', () => { + const lowAttempts = evaluateAutoPausePolicy({ + action: 'pause', + confidence: 0.95, + cooldownMinutes: 60, + enabled: true, + failureRateBps: 100, + failures: 2, + installs: 3, + minAttempts: 10, + }) + + const coolingDown = evaluateAutoPausePolicy({ + action: 'rollback', + confidence: 0.95, + cooldownMinutes: 60, + enabled: true, + failureRateBps: 100, + failures: 100, + installs: 0, + lastTriggeredAt: '2026-05-06T11:30:00.000Z', + now: new Date('2026-05-06T12:00:00.000Z'), + }) + + expect(lowAttempts.reason).toBe('insufficient_attempts') + expect(coolingDown.reason).toBe('cooldown') + }) + + it('uses confidence lower bound before triggering configured action', () => { + const result = evaluateAutoPausePolicy({ + action: 'rollback', + confidence: 0.8, + cooldownMinutes: 0, + enabled: true, + failureRateBps: 5000, + failures: 95, + installs: 5, + minAttempts: 10, + minFailures: 10, + }) + + expect(result.shouldTrigger).toBe(true) + expect(result.action).toBe('rollback') + expect(result.reason).toBe('triggered') + }) +}) diff --git a/tests/updates.test.ts b/tests/updates.test.ts index d41d20df8d..7c3fa3bb79 100644 --- a/tests/updates.test.ts +++ b/tests/updates.test.ts @@ -45,7 +45,7 @@ async function updateChannel( if (!version) { const { data, error } = await getSupabaseClient() .from('channels') - .select('version(name)') + .select('version:app_versions!channels_version_fkey(name)') .eq('app_id', APP_NAME_UPDATE) .eq('name', channel) .single() From 6fd678a3ee2334d4c2582ef7eb0a6bad9c990296 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 6 May 2026 18:57:34 +0200 Subject: [PATCH 02/40] fix: address rollout sonar findings --- supabase/functions/_backend/utils/pg.ts | 41 ++++---- .../20260506154832_random_sticky_rollouts.sql | 93 ++++++++++--------- 2 files changed, 70 insertions(+), 64 deletions(-) diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index 45c86e821d..1bd0ede550 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -664,7 +664,28 @@ export function requestInfosChannelPostgresRollout( includeMetadata = false, ) { const { versionSelect, rolloutVersionSelect, channelAlias, channelSelect, versionAlias, rolloutVersionAlias } = getSchemaUpdatesAlias(includeMetadata) - const platformQuery = platform === 'android' ? channelAlias.android : platform === 'electron' ? channelAlias.electron : channelAlias.ios + let platformQuery = channelAlias.ios + if (platform === 'android') + platformQuery = channelAlias.android + else if (platform === 'electron') + platformQuery = channelAlias.electron + + const channelFilter = defaultChannel + ? and( + eq(channelAlias.app_id, app_id), + eq(channelAlias.name, defaultChannel), + eq(platformQuery, true), + or( + eq(channelAlias.public, true), + eq(channelAlias.allow_device_self_set, true), + ), + ) + : and( + eq(channelAlias.public, true), + eq(channelAlias.app_id, app_id), + eq(platformQuery, true), + ) + const channelQuery = drizzleClient .select({ version: versionSelect, @@ -674,23 +695,7 @@ export function requestInfosChannelPostgresRollout( .from(channelAlias) .innerJoin(versionAlias, activeChannelVersionJoin(channelAlias.version, versionAlias)) .leftJoin(rolloutVersionAlias, activeChannelVersionJoin(channelAlias.rollout_version, rolloutVersionAlias, channelAlias.app_id)) - .where( - !defaultChannel - ? and( - eq(channelAlias.public, true), - eq(channelAlias.app_id, app_id), - eq(platformQuery, true), - ) - : and( - eq(channelAlias.app_id, app_id), - eq(channelAlias.name, defaultChannel), - eq(platformQuery, true), - or( - eq(channelAlias.public, true), - eq(channelAlias.allow_device_self_set, true), - ), - ), - ) + .where(channelFilter) .limit(1) cloudlog({ requestId: c.get('requestId'), message: 'channel rollout Query:', channelQuery: channelQuery.toSQL() }) diff --git a/supabase/migrations/20260506154832_random_sticky_rollouts.sql b/supabase/migrations/20260506154832_random_sticky_rollouts.sql index 299d9b5a94..ce42d237cd 100644 --- a/supabase/migrations/20260506154832_random_sticky_rollouts.sql +++ b/supabase/migrations/20260506154832_random_sticky_rollouts.sql @@ -77,21 +77,21 @@ SECURITY DEFINER SET "search_path" TO '' AS $$ BEGIN - IF "p_app_id" IS NULL THEN + IF p_app_id IS NULL THEN RETURN; END IF; - UPDATE "public"."apps" AS a + UPDATE public.apps AS a SET - "rollout_channel_count" = ( + rollout_channel_count = ( SELECT count(*)::bigint - FROM "public"."channels" AS c - WHERE c."app_id" = "p_app_id" - AND c."rollout_enabled" = true - AND c."rollout_version" IS NOT NULL + FROM public.channels AS c + WHERE c.app_id = p_app_id + AND c.rollout_enabled IS TRUE + AND c.rollout_version IS NOT NULL ), - "updated_at" = now() - WHERE a."app_id" = "p_app_id"; + updated_at = now() + WHERE a.app_id = p_app_id; END; $$; @@ -174,53 +174,54 @@ FROM ( ) AS rollout_counts WHERE rollout_counts."app_id" = a."app_id"; -CREATE OR REPLACE FUNCTION "public"."update_app_versions_retention"() +CREATE OR REPLACE FUNCTION public.update_app_versions_retention() RETURNS void -LANGUAGE "plpgsql" -SET "search_path" TO '' +LANGUAGE plpgsql +SET search_path TO '' AS $$ BEGIN - UPDATE "public"."app_versions" - SET "deleted" = true, "updated_at" = NOW() - WHERE "app_versions"."deleted" = false - AND (SELECT "retention" FROM "public"."apps" WHERE "apps"."app_id" = "app_versions"."app_id") >= 0 - AND (SELECT "retention" FROM "public"."apps" WHERE "apps"."app_id" = "app_versions"."app_id") < 63113904 - AND "app_versions"."created_at" < ( - SELECT NOW() - make_interval(secs => "apps"."retention") - FROM "public"."apps" - WHERE "apps"."app_id" = "app_versions"."app_id" - ) - AND NOT EXISTS ( - SELECT 1 - FROM "public"."channels" - WHERE "channels"."app_id" = "app_versions"."app_id" - AND ( - "channels"."version" = "app_versions"."id" - OR "channels"."rollout_version" = "app_versions"."id" - ) + UPDATE public.app_versions AS av + SET deleted = true, updated_at = NOW() + FROM public.apps AS a + WHERE av.deleted IS FALSE + AND a.app_id = av.app_id + AND a.retention >= 0 + AND a.retention < 63113904 + AND av.created_at < NOW() - make_interval(secs => a.retention) + AND av.id NOT IN ( + SELECT c.version + FROM public.channels AS c + WHERE c.version IS NOT NULL + UNION + SELECT c.rollout_version + FROM public.channels AS c + WHERE c.rollout_version IS NOT NULL ); END; $$; -ALTER FUNCTION "public"."update_app_versions_retention"() OWNER TO "postgres"; +ALTER FUNCTION public.update_app_versions_retention() OWNER TO postgres; -CREATE OR REPLACE FUNCTION "public"."delete_old_deleted_versions"() -RETURNS "void" -LANGUAGE "plpgsql" +CREATE OR REPLACE FUNCTION public.delete_old_deleted_versions() +RETURNS void +LANGUAGE plpgsql SECURITY DEFINER -SET "search_path" TO '' +SET search_path TO '' AS $$ DECLARE deleted_count bigint; BEGIN - DELETE FROM "public"."app_versions" - WHERE "deleted" = true - AND "updated_at" < NOW() - INTERVAL '1 year' - AND NOT EXISTS ( - SELECT 1 - FROM "public"."channels" - WHERE "channels"."version" = "app_versions"."id" - OR "channels"."rollout_version" = "app_versions"."id" + DELETE FROM public.app_versions AS av + WHERE av.deleted IS TRUE + AND av.updated_at < NOW() - INTERVAL '1 year' + AND av.id NOT IN ( + SELECT c.version + FROM public.channels AS c + WHERE c.version IS NOT NULL + UNION + SELECT c.rollout_version + FROM public.channels AS c + WHERE c.rollout_version IS NOT NULL ); GET DIAGNOSTICS deleted_count = ROW_COUNT; @@ -231,9 +232,9 @@ BEGIN END; $$; -ALTER FUNCTION "public"."delete_old_deleted_versions"() OWNER TO "postgres"; -REVOKE EXECUTE ON FUNCTION "public"."delete_old_deleted_versions"() FROM public; -GRANT EXECUTE ON FUNCTION "public"."delete_old_deleted_versions"() TO service_role; +ALTER FUNCTION public.delete_old_deleted_versions() OWNER TO postgres; +REVOKE EXECUTE ON FUNCTION public.delete_old_deleted_versions() FROM public; +GRANT EXECUTE ON FUNCTION public.delete_old_deleted_versions() TO service_role; SELECT pgmq.create('cron_rollout_auto_pause'); From 5efc9de30914a5cdd24a3142fdcde0d188184319 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 6 May 2026 19:13:02 +0200 Subject: [PATCH 03/40] fix: satisfy backend lint --- .../functions/_backend/triggers/cron_rollout_auto_pause.ts | 3 ++- supabase/functions/_backend/utils/pg.ts | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/supabase/functions/_backend/triggers/cron_rollout_auto_pause.ts b/supabase/functions/_backend/triggers/cron_rollout_auto_pause.ts index 4534fbd50b..ea6bfc4f67 100644 --- a/supabase/functions/_backend/triggers/cron_rollout_auto_pause.ts +++ b/supabase/functions/_backend/triggers/cron_rollout_auto_pause.ts @@ -1,8 +1,9 @@ import type { MiddlewareKeyVariables } from '../utils/hono.ts' +import type { AutoPauseAction } from '../utils/rollout.ts' import { Hono } from 'hono/tiny' import { BRES, middlewareAPISecret } from '../utils/hono.ts' import { cloudlog, cloudlogErr } from '../utils/logging.ts' -import { evaluateAutoPausePolicy, type AutoPauseAction } from '../utils/rollout.ts' +import { evaluateAutoPausePolicy } from '../utils/rollout.ts' import { readStatsVersion } from '../utils/stats.ts' import { supabaseAdmin } from '../utils/supabase.ts' diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index 1bd0ede550..62761f01f1 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -10,9 +10,9 @@ import { CacheHelper } from './cache.ts' import { DISPOSABLE_EMAIL_DOMAINS, PERSONAL_EMAIL_DOMAINS } from './emailClassification.ts' import { getClientDbRegionSB } from './geolocation.ts' import { cloudlog, cloudlogErr } from './logging.ts' -import { getRolloutDecision } from './rollout.ts' import * as schema from './postgres_schema.ts' import { withOptionalManifestSelect } from './queryHelpers.ts' +import { getRolloutDecision } from './rollout.ts' const REPLICATION_LAG_THRESHOLD_SECONDS = 180 const REPLICATION_LAG_CACHE_TTL_SECONDS = 60 @@ -607,7 +607,6 @@ export function requestInfosChannelPostgres( return channel } - export function requestManifestEntriesPostgres( c: Context, versionId: number, From 29e0d812eba6825a3631e2eb15692be8091331b7 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 6 May 2026 19:27:24 +0200 Subject: [PATCH 04/40] fix: preserve special versions during rollout retention --- .../20260506154832_random_sticky_rollouts.sql | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/supabase/migrations/20260506154832_random_sticky_rollouts.sql b/supabase/migrations/20260506154832_random_sticky_rollouts.sql index ce42d237cd..140421c3aa 100644 --- a/supabase/migrations/20260506154832_random_sticky_rollouts.sql +++ b/supabase/migrations/20260506154832_random_sticky_rollouts.sql @@ -181,21 +181,20 @@ SET search_path TO '' AS $$ BEGIN UPDATE public.app_versions AS av - SET deleted = true, updated_at = NOW() - FROM public.apps AS a - WHERE av.deleted IS FALSE - AND a.app_id = av.app_id - AND a.retention >= 0 - AND a.retention < 63113904 - AND av.created_at < NOW() - make_interval(secs => a.retention) - AND av.id NOT IN ( - SELECT c.version + SET deleted = true + WHERE av.deleted = false + AND (SELECT retention FROM public.apps WHERE apps.app_id = av.app_id) >= 0 + AND (SELECT retention FROM public.apps WHERE apps.app_id = av.app_id) < 63113904 + AND av.created_at < ( + SELECT NOW() - make_interval(secs => apps.retention) + FROM public.apps + WHERE apps.app_id = av.app_id + ) + AND NOT EXISTS ( + SELECT 1 FROM public.channels AS c - WHERE c.version IS NOT NULL - UNION - SELECT c.rollout_version - FROM public.channels AS c - WHERE c.rollout_version IS NOT NULL + WHERE c.app_id = av.app_id + AND (c.version = av.id OR c.rollout_version = av.id) ); END; $$; @@ -212,16 +211,14 @@ DECLARE deleted_count bigint; BEGIN DELETE FROM public.app_versions AS av - WHERE av.deleted IS TRUE - AND av.updated_at < NOW() - INTERVAL '1 year' - AND av.id NOT IN ( - SELECT c.version - FROM public.channels AS c - WHERE c.version IS NOT NULL - UNION - SELECT c.rollout_version + WHERE av.deleted_at IS NOT NULL + AND av.deleted_at < NOW() - INTERVAL '3 months' + AND av.name NOT IN ('builtin', 'unknown') + AND NOT EXISTS ( + SELECT 1 FROM public.channels AS c - WHERE c.rollout_version IS NOT NULL + WHERE c.app_id = av.app_id + AND (c.version = av.id OR c.rollout_version = av.id) ); GET DIAGNOSTICS deleted_count = ROW_COUNT; From 3e5a5021576d942e3445055c74a9c73b37568487 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 6 May 2026 19:49:36 +0200 Subject: [PATCH 05/40] fix: address rollout review feedback --- src/pages/app/[app].channel.[channel].vue | 18 ++++++- .../triggers/cron_rollout_auto_pause.ts | 48 +++++++++---------- 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/src/pages/app/[app].channel.[channel].vue b/src/pages/app/[app].channel.[channel].vue index ad63b86244..5b04230ca9 100644 --- a/src/pages/app/[app].channel.[channel].vue +++ b/src/pages/app/[app].channel.[channel].vue @@ -466,6 +466,22 @@ async function saveRolloutPercentage(value: string) { await saveChannelChange('rollout_percentage_bps', Math.round(percentage * 100) as any) } +async function saveAutoPauseFailureRate(value: string) { + const trimmedValue = value.trim() + if (!trimmedValue) { + await saveChannelChange('auto_pause_failure_rate_bps', null as any) + return + } + + const failureRateBps = Number(trimmedValue) + if (!Number.isFinite(failureRateBps) || failureRateBps < 0 || failureRateBps > 10000) { + toast.error(t('error-update-channel')) + return + } + + await saveChannelChange('auto_pause_failure_rate_bps', Math.round(failureRateBps) as any) +} + async function rollbackRollout() { await Promise.all([ saveChannelChange('rollout_version', null as any), @@ -881,7 +897,7 @@ async function copyCurlCommand() { :disabled="!canPromoteBundle" :placeholder="t('failure-rate-bps')" :value="channel.auto_pause_failure_rate_bps ?? ''" - @change="saveChannelChange('auto_pause_failure_rate_bps', Number(($event.target as HTMLInputElement).value) as any)" + @change="saveAutoPauseFailureRate(($event.target as HTMLInputElement).value)" > diff --git a/supabase/functions/_backend/triggers/cron_rollout_auto_pause.ts b/supabase/functions/_backend/triggers/cron_rollout_auto_pause.ts index 13b542b1f1..966b8ee749 100644 --- a/supabase/functions/_backend/triggers/cron_rollout_auto_pause.ts +++ b/supabase/functions/_backend/triggers/cron_rollout_auto_pause.ts @@ -1,11 +1,10 @@ -import type { MiddlewareKeyVariables } from '../utils/hono.ts' import type { AutoPauseAction } from '../utils/rollout.ts' -import { Hono } from 'hono/tiny' -import { BRES, middlewareAPISecret } from '../utils/hono.ts' +import { BRES, createHono, middlewareAPISecret } from '../utils/hono.ts' import { cloudlog, cloudlogErr } from '../utils/logging.ts' import { evaluateAutoPausePolicy } from '../utils/rollout.ts' import { readStatsVersion } from '../utils/stats.ts' import { supabaseAdmin } from '../utils/supabase.ts' +import { version } from '../utils/version.ts' interface RolloutAutoPauseChannel { app_id: string @@ -26,7 +25,7 @@ interface RolloutAutoPauseChannel { rollout_version_info?: { name: string } | { name: string }[] | null } -export const app = new Hono() +export const app = createHono('', version) function normalizeAction(action: string): AutoPauseAction { if (action === 'rollback' || action === 'notify') diff --git a/supabase/migrations/20260506154832_random_sticky_rollouts.sql b/supabase/migrations/20260506154832_random_sticky_rollouts.sql index 1751da0c03..e0c705048c 100644 --- a/supabase/migrations/20260506154832_random_sticky_rollouts.sql +++ b/supabase/migrations/20260506154832_random_sticky_rollouts.sql @@ -91,6 +91,7 @@ BEGIN FROM public.channels AS c WHERE c.app_id = p_app_id AND c.rollout_version IS NOT NULL + AND c.rollout_enabled = true ), updated_at = now() WHERE a.app_id = p_app_id; @@ -181,6 +182,7 @@ FROM ( count(*)::bigint AS rollout_count FROM "public"."channels" AS c WHERE c."rollout_version" IS NOT NULL + AND c."rollout_enabled" = true GROUP BY c."app_id" ) AS rollout_counts WHERE rollout_counts."app_id" = a."app_id"; @@ -244,9 +246,10 @@ END; $$; ALTER FUNCTION public.read_version_usage(character varying, timestamp without time zone, timestamp without time zone, text) OWNER TO postgres; -GRANT ALL ON FUNCTION public.read_version_usage(character varying, timestamp without time zone, timestamp without time zone, text) TO anon; -GRANT ALL ON FUNCTION public.read_version_usage(character varying, timestamp without time zone, timestamp without time zone, text) TO authenticated; -GRANT ALL ON FUNCTION public.read_version_usage(character varying, timestamp without time zone, timestamp without time zone, text) TO service_role; +REVOKE ALL ON FUNCTION public.read_version_usage(character varying, timestamp without time zone, timestamp without time zone, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.read_version_usage(character varying, timestamp without time zone, timestamp without time zone, text) TO anon; +GRANT EXECUTE ON FUNCTION public.read_version_usage(character varying, timestamp without time zone, timestamp without time zone, text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.read_version_usage(character varying, timestamp without time zone, timestamp without time zone, text) TO service_role; CREATE OR REPLACE FUNCTION public.delete_old_deleted_versions() RETURNS void diff --git a/tests/updates.test.ts b/tests/updates.test.ts index 69f470f633..320215e203 100644 --- a/tests/updates.test.ts +++ b/tests/updates.test.ts @@ -266,7 +266,7 @@ describe('[POST] /updates', () => { expect(json.checksum).toBe(expectedFallbackJson.checksum) }) - it('keeps rollout-aware path for disabled rollout targets', async () => { + it('keeps disabled rollout targets on the fast path', async () => { const supabase = getSupabaseClient() const rolloutVersionName = `1.2.${Math.floor(Math.random() * 100000) + 1000}` const rolloutVersion = await createAppVersions(rolloutVersionName, APP_NAME_UPDATE, { @@ -302,7 +302,7 @@ describe('[POST] /updates', () => { .single() .throwOnError() - expect(app.rollout_channel_count).toBeGreaterThan(0) + expect(app.rollout_channel_count).toBe(0) const baseData = getBaseData(APP_NAME_UPDATE) baseData.version_name = rolloutVersionName @@ -312,8 +312,7 @@ describe('[POST] /updates', () => { expect(response.status).toBe(200) const json = await response.json() - expect(json.error).toBe('no_new_version_available') - expect(json.kind).toBe('up_to_date') + expect(json.version).not.toBe(rolloutVersionName) } finally { await supabase From 64a52758e8e6e8d6103fa33bb961b0a86fc00268 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 6 May 2026 21:42:16 +0200 Subject: [PATCH 12/40] fix: validate rollout bundle compatibility --- cli/src/channel/set.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/cli/src/channel/set.ts b/cli/src/channel/set.ts index 8bdef315e0..1d290f55e7 100644 --- a/cli/src/channel/set.ts +++ b/cli/src/channel/set.ts @@ -336,6 +336,36 @@ export async function setChannelInternal(channel: string, appId: string, options if (rolloutBundle != null) { const data = await findRemoteBundle(rolloutBundle) + + if (!options.ignoreMetadataCheck) { + const { finalCompatibility, localDependencies } = await checkCompatibilityNativePackages( + supabase, + appId, + channel, + (data.native_packages as any) ?? [], + ) + + const incompatiblePackages = finalCompatibility.filter(item => !isCompatible(item)) + + if (localDependencies.length > 0 && incompatiblePackages.length > 0) { + if (!silent) { + log.warn(`Rollout bundle NOT compatible with ${channel} channel`) + log.warn('') + displayCompatibilityTable(finalCompatibility) + log.warn('') + log.warn('An app store update may be required for these changes to take effect.') + } + throw new Error(`Rollout bundle is not compatible with ${channel} channel`) + } + + if (!silent) { + if (localDependencies.length === 0 && finalCompatibility.length > 0) + log.info(`Ignoring check compatibility with ${channel} channel because the rollout bundle does not contain any native packages`) + else + log.info(`Rollout bundle is compatible with ${channel} channel`) + } + } + channelPayload.rollout_version = data.id if (rolloutEnable == null) channelPayload.rollout_enabled = true From 2d539c37aeb8d96c774d3378163e93ead440dd1f Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 6 May 2026 21:55:43 +0200 Subject: [PATCH 13/40] fix: enforce disabled rollout decisions --- supabase/functions/_backend/utils/rollout.ts | 28 ++++++++-------- tests/rollout.test.ts | 34 +++++++++++++++++++- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/supabase/functions/_backend/utils/rollout.ts b/supabase/functions/_backend/utils/rollout.ts index e356a847dc..0f4dc5b738 100644 --- a/supabase/functions/_backend/utils/rollout.ts +++ b/supabase/functions/_backend/utils/rollout.ts @@ -125,42 +125,42 @@ export function resolveRolloutDecision(input: RolloutDecisionInput): RolloutDeci const ttlSeconds = sanitizeRolloutCacheTtlSeconds(input.rolloutCacheTtlSeconds) const cached = isMatchingCachedDecision(input, input.cachePayload) ? input.cachePayload : null - if (input.currentVersionName === input.rolloutVersionName) { + if (!input.rolloutEnabled) { return { - selected: true, - shouldWriteCache: true, - payload: cached?.selected ? updatePayload(input, cached, true, Math.max(cached.percentage_bps, percentageBps)) : buildPayload(input, true, percentageBps), - reason: 'already_on_rollout', + selected: false, + shouldWriteCache: false, + payload: cached, + reason: 'disabled', ttlSeconds, } } - if (cached?.selected) { + if (input.currentVersionName === input.rolloutVersionName) { return { selected: true, - shouldWriteCache: false, - payload: cached, - reason: 'cached_selected', + shouldWriteCache: true, + payload: cached?.selected ? updatePayload(input, cached, true, Math.max(cached.percentage_bps, percentageBps)) : buildPayload(input, true, percentageBps), + reason: 'already_on_rollout', ttlSeconds, } } - if (!input.rolloutEnabled) { + if (input.rolloutPausedAt) { return { selected: false, shouldWriteCache: false, payload: cached, - reason: 'disabled', + reason: 'paused', ttlSeconds, } } - if (input.rolloutPausedAt) { + if (cached?.selected) { return { - selected: false, + selected: true, shouldWriteCache: false, payload: cached, - reason: 'paused', + reason: 'cached_selected', ttlSeconds, } } diff --git a/tests/rollout.test.ts b/tests/rollout.test.ts index 79f14756f5..43451573f4 100644 --- a/tests/rollout.test.ts +++ b/tests/rollout.test.ts @@ -100,7 +100,7 @@ describe('rollout decisions', () => { const decision = resolveRolloutDecision({ ...baseDecision, currentVersionName: '1.1.0', - rolloutEnabled: false, + rolloutEnabled: true, rolloutPausedAt: '2026-05-06T11:30:00.000Z', rolloutPercentageBps: 0, }) @@ -110,6 +110,18 @@ describe('rollout decisions', () => { expect(decision.payload?.selected).toBe(true) }) + it.concurrent('moves devices already on rollout back to stable when disabled', () => { + const decision = resolveRolloutDecision({ + ...baseDecision, + currentVersionName: '1.1.0', + rolloutEnabled: false, + rolloutPercentageBps: 10000, + }) + + expect(decision.selected).toBe(false) + expect(decision.reason).toBe('disabled') + }) + it.concurrent('does not expose new devices while paused', () => { const decision = resolveRolloutDecision({ ...baseDecision, @@ -121,6 +133,26 @@ describe('rollout decisions', () => { expect(decision.selected).toBe(false) expect(decision.reason).toBe('paused') }) + + it.concurrent('does not expose cached selected devices while paused unless already installed', () => { + const decision = resolveRolloutDecision({ + ...baseDecision, + cachePayload: { + selected: true, + percentage_bps: 10000, + rollout_id: baseDecision.rolloutId, + rollout_version: baseDecision.rolloutVersionId, + created_at: '2026-05-06T11:00:00.000Z', + updated_at: '2026-05-06T11:00:00.000Z', + }, + rolloutPausedAt: '2026-05-06T11:30:00.000Z', + rolloutPercentageBps: 10000, + randomBps: () => 0, + }) + + expect(decision.selected).toBe(false) + expect(decision.reason).toBe('paused') + }) }) describe('rollout auto-pause policy', () => { From 6cc346d980f45a5d15f8f8cf59346d419626830e Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 6 May 2026 22:05:10 +0200 Subject: [PATCH 14/40] fix: preserve paused rollout fast path --- cli/src/types/supabase.types.ts | 4 + src/pages/app/[app].channel.[channel].vue | 4 +- src/types/supabase.types.ts | 4 + supabase/functions/_backend/utils/pg.ts | 6 +- .../_backend/utils/postgres_schema.ts | 1 + .../_backend/utils/supabase.types.ts | 4 + supabase/functions/_backend/utils/update.ts | 4 +- .../20260506154832_random_sticky_rollouts.sql | 46 +++++++--- tests/updates.test.ts | 85 ++++++++++++++++++- 9 files changed, 140 insertions(+), 18 deletions(-) diff --git a/cli/src/types/supabase.types.ts b/cli/src/types/supabase.types.ts index 1e58caa628..70454a4aba 100644 --- a/cli/src/types/supabase.types.ts +++ b/cli/src/types/supabase.types.ts @@ -267,6 +267,7 @@ export type Database = { last_version: string | null manifest_bundle_count: number rollout_channel_count: number + rollout_paused_version_names: string[] name: string | null need_onboarding: boolean owner_org: string @@ -293,6 +294,7 @@ export type Database = { last_version?: string | null manifest_bundle_count?: number rollout_channel_count?: number + rollout_paused_version_names?: string[] name?: string | null need_onboarding?: boolean owner_org: string @@ -319,6 +321,7 @@ export type Database = { last_version?: string | null manifest_bundle_count?: number rollout_channel_count?: number + rollout_paused_version_names?: string[] name?: string | null need_onboarding?: boolean owner_org?: string @@ -3251,6 +3254,7 @@ export type Database = { last_version: string | null manifest_bundle_count: number rollout_channel_count: number + rollout_paused_version_names: string[] name: string | null need_onboarding: boolean owner_org: string diff --git a/src/pages/app/[app].channel.[channel].vue b/src/pages/app/[app].channel.[channel].vue index f1f0c8402e..6c5b5fb40e 100644 --- a/src/pages/app/[app].channel.[channel].vue +++ b/src/pages/app/[app].channel.[channel].vue @@ -303,8 +303,8 @@ async function handleVersionLink(appVersion: Database['public']['Tables']['app_v return } - await saveChannelChange('version', appVersion.id) - toast.success(t('linked-bundle')) + if (await saveChannelChange('version', appVersion.id)) + toast.success(t('linked-bundle')) } async function getUnknownVersion(): Promise { diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index 0adca81e6e..1cde4e7a6f 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -267,6 +267,7 @@ export type Database = { last_version: string | null manifest_bundle_count: number rollout_channel_count: number + rollout_paused_version_names: string[] name: string | null need_onboarding: boolean owner_org: string @@ -293,6 +294,7 @@ export type Database = { last_version?: string | null manifest_bundle_count?: number rollout_channel_count?: number + rollout_paused_version_names?: string[] name?: string | null need_onboarding?: boolean owner_org: string @@ -319,6 +321,7 @@ export type Database = { last_version?: string | null manifest_bundle_count?: number rollout_channel_count?: number + rollout_paused_version_names?: string[] name?: string | null need_onboarding?: boolean owner_org?: string @@ -3290,6 +3293,7 @@ export type Database = { last_version: string | null manifest_bundle_count: number rollout_channel_count: number + rollout_paused_version_names: string[] name: string | null need_onboarding: boolean owner_org: string diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index a52c0ff94d..f6691c8ef4 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -782,12 +782,14 @@ export function requestInfosPostgres( channelDeviceCount: number | null | undefined, manifestBundleCount: number | null | undefined, rolloutChannelCount: number | null | undefined, + rolloutPausedVersionNames: string[] | null | undefined, currentVersionName: string, includeMetadata = false, ) { const shouldQueryChannelOverride = channelDeviceCount === undefined || channelDeviceCount === null ? true : channelDeviceCount > 0 const shouldFetchManifest = manifestBundleCount === undefined || manifestBundleCount === null ? true : manifestBundleCount > 0 - const shouldUseRolloutPath = rolloutChannelCount === undefined || rolloutChannelCount === null ? false : rolloutChannelCount > 0 + const isPausedRolloutVersion = Array.isArray(rolloutPausedVersionNames) && rolloutPausedVersionNames.includes(currentVersionName) + const shouldUseRolloutPath = (rolloutChannelCount ?? 0) > 0 || isPausedRolloutVersion if (!shouldUseRolloutPath) { const channelDevice = shouldQueryChannelOverride @@ -837,6 +839,7 @@ export interface AppOwnerPostgresResult { channel_device_count: number manifest_bundle_count: number rollout_channel_count: number + rollout_paused_version_names: string[] expose_metadata: boolean allow_device_custom_id: boolean } @@ -860,6 +863,7 @@ export async function getAppOwnerPostgres( channel_device_count: schema.apps.channel_device_count, manifest_bundle_count: schema.apps.manifest_bundle_count, rollout_channel_count: schema.apps.rollout_channel_count, + rollout_paused_version_names: schema.apps.rollout_paused_version_names, expose_metadata: schema.apps.expose_metadata, allow_device_custom_id: schema.apps.allow_device_custom_id, orgs: { diff --git a/supabase/functions/_backend/utils/postgres_schema.ts b/supabase/functions/_backend/utils/postgres_schema.ts index 5d02c1d191..59ae4f03c9 100644 --- a/supabase/functions/_backend/utils/postgres_schema.ts +++ b/supabase/functions/_backend/utils/postgres_schema.ts @@ -29,6 +29,7 @@ export const apps = pgTable('apps', { channel_device_count: bigint('channel_device_count', { mode: 'number' }).notNull().default(0), manifest_bundle_count: bigint('manifest_bundle_count', { mode: 'number' }).notNull().default(0), rollout_channel_count: bigint('rollout_channel_count', { mode: 'number' }).notNull().default(0), + rollout_paused_version_names: varchar('rollout_paused_version_names').array().notNull(), expose_metadata: boolean('expose_metadata').notNull().default(false), allow_device_custom_id: boolean('allow_device_custom_id').notNull().default(true), need_onboarding: boolean('need_onboarding').notNull().default(false), diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index 0adca81e6e..1cde4e7a6f 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -267,6 +267,7 @@ export type Database = { last_version: string | null manifest_bundle_count: number rollout_channel_count: number + rollout_paused_version_names: string[] name: string | null need_onboarding: boolean owner_org: string @@ -293,6 +294,7 @@ export type Database = { last_version?: string | null manifest_bundle_count?: number rollout_channel_count?: number + rollout_paused_version_names?: string[] name?: string | null need_onboarding?: boolean owner_org: string @@ -319,6 +321,7 @@ export type Database = { last_version?: string | null manifest_bundle_count?: number rollout_channel_count?: number + rollout_paused_version_names?: string[] name?: string | null need_onboarding?: boolean owner_org?: string @@ -3290,6 +3293,7 @@ export type Database = { last_version: string | null manifest_bundle_count: number rollout_channel_count: number + rollout_paused_version_names: string[] name: string | null need_onboarding: boolean owner_org: string diff --git a/supabase/functions/_backend/utils/update.ts b/supabase/functions/_backend/utils/update.ts index 6b8961c7a8..80286a9dd4 100644 --- a/supabase/functions/_backend/utils/update.ts +++ b/supabase/functions/_backend/utils/update.ts @@ -142,6 +142,7 @@ export async function updateWithPG( const channelDeviceCount = appOwner.channel_device_count ?? 0 const manifestBundleCount = appOwner.manifest_bundle_count ?? 0 const rolloutChannelCount = appOwner.rollout_channel_count ?? 0 + const rolloutPausedVersionNames = appOwner.rollout_paused_version_names ?? [] const bypassChannelOverrides = channelDeviceCount <= 0 const pluginVersion = parse(plugin_version) // v5 is deprecated if < 5.10.0, v6 is deprecated if < 6.25.0, v7 is deprecated if < 7.25.0 @@ -156,6 +157,7 @@ export async function updateWithPG( bypassChannelOverrides, manifestBundleCount, rolloutChannelCount, + rolloutPausedVersionCount: rolloutPausedVersionNames.length, fetchManifestEntries, }) if (body.version_build === 'unknown') { @@ -205,7 +207,7 @@ export async function updateWithPG( // Only query link/comment if plugin supports it (v5.35.0+, v6.35.0+, v7.35.0+, v8.35.0+) AND app has expose_metadata enabled const needsMetadata = appOwner.expose_metadata && !isDeprecatedPluginVersion(pluginVersion, '5.35.0', '6.35.0', '7.35.0', '8.35.0') - const requestedInto = await requestInfosPostgres(c, platform, app_id, device_id, defaultChannel, drizzleClient, channelDeviceCount, manifestBundleCount, rolloutChannelCount, version_name, needsMetadata) + const requestedInto = await requestInfosPostgres(c, platform, app_id, device_id, defaultChannel, drizzleClient, channelDeviceCount, manifestBundleCount, rolloutChannelCount, rolloutPausedVersionNames, version_name, needsMetadata) const { channelOverride } = requestedInto let { channelData } = requestedInto cloudlog({ requestId: c.get('requestId'), message: `channelData exists ? ${channelData !== undefined}, channelOverride exists ? ${channelOverride !== undefined}` }) diff --git a/supabase/migrations/20260506154832_random_sticky_rollouts.sql b/supabase/migrations/20260506154832_random_sticky_rollouts.sql index e0c705048c..b947c97f61 100644 --- a/supabase/migrations/20260506154832_random_sticky_rollouts.sql +++ b/supabase/migrations/20260506154832_random_sticky_rollouts.sql @@ -1,5 +1,6 @@ ALTER TABLE "public"."apps" -ADD COLUMN "rollout_channel_count" bigint NOT NULL DEFAULT 0; +ADD COLUMN "rollout_channel_count" bigint NOT NULL DEFAULT 0, +ADD COLUMN "rollout_paused_version_names" character varying[] NOT NULL DEFAULT '{}'::character varying[]; ALTER TABLE "public"."version_usage" ADD COLUMN "channel_name" character varying(255); @@ -92,6 +93,17 @@ BEGIN WHERE c.app_id = p_app_id AND c.rollout_version IS NOT NULL AND c.rollout_enabled = true + AND c.rollout_paused_at IS NULL + ), + rollout_paused_version_names = ARRAY( + SELECT DISTINCT rv.name + FROM public.channels AS c + INNER JOIN public.app_versions AS rv ON rv.id = c.rollout_version AND rv.app_id = c.app_id + WHERE c.app_id = p_app_id + AND c.rollout_version IS NOT NULL + AND c.rollout_enabled = true + AND c.rollout_paused_at IS NOT NULL + ORDER BY rv.name ), updated_at = now() WHERE a.app_id = p_app_id; @@ -170,22 +182,30 @@ EXECUTE FUNCTION "public"."refresh_channel_rollout_id"(); DROP TRIGGER IF EXISTS "refresh_app_rollout_channel_count" ON "public"."channels"; CREATE TRIGGER "refresh_app_rollout_channel_count" -AFTER INSERT OR UPDATE OF "app_id", "rollout_enabled", "rollout_version" OR DELETE ON "public"."channels" +AFTER INSERT OR UPDATE OF "app_id", "rollout_enabled", "rollout_version", "rollout_paused_at" OR DELETE ON "public"."channels" FOR EACH ROW EXECUTE FUNCTION "public"."refresh_app_rollout_channel_count"(); UPDATE "public"."apps" AS a -SET "rollout_channel_count" = rollout_counts.rollout_count -FROM ( - SELECT - c."app_id", - count(*)::bigint AS rollout_count - FROM "public"."channels" AS c - WHERE c."rollout_version" IS NOT NULL - AND c."rollout_enabled" = true - GROUP BY c."app_id" -) AS rollout_counts -WHERE rollout_counts."app_id" = a."app_id"; +SET + "rollout_channel_count" = ( + SELECT count(*)::bigint + FROM "public"."channels" AS c + WHERE c."app_id" = a."app_id" + AND c."rollout_version" IS NOT NULL + AND c."rollout_enabled" = true + AND c."rollout_paused_at" IS NULL + ), + "rollout_paused_version_names" = ARRAY( + SELECT DISTINCT rv."name" + FROM "public"."channels" AS c + INNER JOIN "public"."app_versions" AS rv ON rv."id" = c."rollout_version" AND rv."app_id" = c."app_id" + WHERE c."app_id" = a."app_id" + AND c."rollout_version" IS NOT NULL + AND c."rollout_enabled" = true + AND c."rollout_paused_at" IS NOT NULL + ORDER BY rv."name" + ); CREATE OR REPLACE FUNCTION public.update_app_versions_retention() RETURNS void diff --git a/tests/updates.test.ts b/tests/updates.test.ts index 320215e203..17fc56798e 100644 --- a/tests/updates.test.ts +++ b/tests/updates.test.ts @@ -297,12 +297,13 @@ describe('[POST] /updates', () => { try { const { data: app } = await supabase .from('apps') - .select('rollout_channel_count') + .select('rollout_channel_count,rollout_paused_version_names') .eq('app_id', APP_NAME_UPDATE) .single() .throwOnError() expect(app.rollout_channel_count).toBe(0) + expect(app.rollout_paused_version_names).not.toContain(rolloutVersionName) const baseData = getBaseData(APP_NAME_UPDATE) baseData.version_name = rolloutVersionName @@ -330,6 +331,88 @@ describe('[POST] /updates', () => { } }) + it('keeps paused rollout targets available only for devices already on that version', async () => { + const supabase = getSupabaseClient() + const rolloutVersionName = `1.3.${Math.floor(Math.random() * 100000) + 1000}` + const rolloutVersion = await createAppVersions(rolloutVersionName, APP_NAME_UPDATE, { + external_url: `https://example.com/paused-rollout-${rolloutVersionName}.zip`, + }) + + const { data: productionChannel } = await supabase + .from('channels') + .select('id,rollout_version,rollout_enabled,rollout_percentage_bps,rollout_paused_at,rollout_pause_reason') + .eq('app_id', APP_NAME_UPDATE) + .eq('name', 'production') + .single() + .throwOnError() + + await supabase + .from('channels') + .update({ + rollout_version: rolloutVersion.id, + rollout_enabled: true, + rollout_percentage_bps: 10000, + }) + .eq('id', productionChannel.id) + .eq('app_id', APP_NAME_UPDATE) + .throwOnError() + + await supabase + .from('channels') + .update({ + rollout_paused_at: new Date().toISOString(), + rollout_pause_reason: 'test pause', + }) + .eq('id', productionChannel.id) + .eq('app_id', APP_NAME_UPDATE) + .throwOnError() + + try { + const { data: app } = await supabase + .from('apps') + .select('rollout_channel_count,rollout_paused_version_names') + .eq('app_id', APP_NAME_UPDATE) + .single() + .throwOnError() + + expect(app.rollout_channel_count).toBe(0) + expect(app.rollout_paused_version_names).toContain(rolloutVersionName) + + const existingRolloutDevice = getBaseData(APP_NAME_UPDATE) + existingRolloutDevice.version_name = rolloutVersionName + existingRolloutDevice.version_build = rolloutVersionName + + const existingResponse = await postUpdateAfterChannelMutation(existingRolloutDevice) + expect(existingResponse.status).toBe(200) + const existingJson = await existingResponse.json() + expect(existingJson.error).toBe('no_new_version_available') + expect(existingJson.kind).toBe('up_to_date') + + const stableDevice = getBaseData(APP_NAME_UPDATE) + stableDevice.version_name = '0.0.0' + stableDevice.version_build = '0.0.0' + + const stableResponse = await postUpdateAfterChannelMutation(stableDevice) + expect(stableResponse.status).toBe(200) + const stableJson = await stableResponse.json() + expect(stableJson.version).not.toBe(rolloutVersionName) + } + finally { + await supabase + .from('channels') + .update({ + rollout_version: productionChannel.rollout_version, + rollout_enabled: productionChannel.rollout_enabled, + rollout_percentage_bps: productionChannel.rollout_percentage_bps, + rollout_paused_at: productionChannel.rollout_paused_at, + rollout_pause_reason: productionChannel.rollout_pause_reason, + }) + .eq('id', productionChannel.id) + .eq('app_id', APP_NAME_UPDATE) + .throwOnError() + } + }) + it('keeps builtin channel targets addressable', async () => { const supabase = getSupabaseClient() const { data: productionChannel } = await supabase From 2e8ebadf9b3710efd33519c35634b668c8a23ee6 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 6 May 2026 22:09:29 +0200 Subject: [PATCH 15/40] fix: align rollout failure stats cohort --- supabase/functions/_backend/plugins/stats.ts | 4 +- tests/stats.test.ts | 45 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/supabase/functions/_backend/plugins/stats.ts b/supabase/functions/_backend/plugins/stats.ts index 7ac6d73282..b06aa3c43b 100644 --- a/supabase/functions/_backend/plugins/stats.ts +++ b/supabase/functions/_backend/plugins/stats.ts @@ -145,8 +145,8 @@ async function post(c: Context, drizzleClient: ReturnType { }) describe('rollout trigger metadata', () => { + it('records version usage failures only for production devices', async () => { + const shortId = randomUUID().split('-')[0] + const appId = `${APP_NAME}.rollout.failcohort.${shortId}` + await resetAndSeedAppData(appId) + await resetAndSeedAppDataStats(appId) + const supabase = getSupabaseClient() + + try { + const version = await createAppVersions(`1.0.0-failcohort-${shortId}.1`, appId) + const cases = [ + { deviceId: randomUUID().toLowerCase(), isEmulator: true, isProd: true }, + { deviceId: randomUUID().toLowerCase(), isEmulator: false, isProd: false }, + { deviceId: randomUUID().toLowerCase(), isEmulator: false, isProd: true }, + ] + + for (const item of cases) { + const baseData = getBaseData(appId) as StatsPayload + baseData.action = 'update_fail' + baseData.device_id = item.deviceId + baseData.version_build = version.name + baseData.version_name = version.name + baseData.is_emulator = item.isEmulator + baseData.is_prod = item.isProd + + const response = await postStats(baseData) + const responseData = await response.json() + expect(response.status, JSON.stringify(responseData)).toBe(200) + expect(responseData).toEqual({ status: 'ok' }) + } + + const { data, error } = await supabase + .from('version_usage') + .select('action') + .eq('app_id', appId) + .eq('version_name', version.name) + .eq('action', 'fail') + + expect(error).toBeNull() + expect(data).toHaveLength(1) + } + finally { + await resetAppData(appId) + await resetAppDataStats(appId) + } + }) it('preserves explicit auto-pause rollback metadata when clearing rollout version', async () => { const shortId = randomUUID().split('-')[0] const appId = `${APP_NAME}.rollout.trigger.${shortId}` From d8d7da343bfd72b702802495c1e10e256a89ea2e Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Thu, 7 May 2026 14:41:53 +0200 Subject: [PATCH 16/40] feat(ui): expose rollout policy controls --- messages/en.json | 10 +- src/pages/app/[app].channel.[channel].vue | 174 +++++++++++++++++----- 2 files changed, 149 insertions(+), 35 deletions(-) diff --git a/messages/en.json b/messages/en.json index 7aa91e5e6a..efaa9bae23 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1892,5 +1892,13 @@ "notify": "Notify", "auto-pause": "Auto-pause", "failure-rate-bps": "Failure bps", - "current-rollout-target": "Current rollout target" + "current-rollout-target": "Current rollout target", + "rollout-policy": "Rollout policy", + "cache-ttl-seconds": "Cache TTL seconds", + "auto-pause-action": "Action", + "window-minutes": "Window minutes", + "confidence": "Confidence", + "min-attempts": "Min attempts", + "min-failures": "Min failures", + "cooldown-minutes": "Cooldown minutes" } diff --git a/src/pages/app/[app].channel.[channel].vue b/src/pages/app/[app].channel.[channel].vue index 6c5b5fb40e..f0d2b5df9f 100644 --- a/src/pages/app/[app].channel.[channel].vue +++ b/src/pages/app/[app].channel.[channel].vue @@ -471,20 +471,34 @@ async function saveRolloutPercentage(value: string) { await saveChannelChange('rollout_percentage_bps', Math.round(percentage * 100) as any) } -async function saveAutoPauseFailureRate(value: string) { +async function saveIntegerField(key: EditableChannelKey, value: string, min: number, max: number, nullable = false) { const trimmedValue = value.trim() - if (!trimmedValue) { - await saveChannelChange('auto_pause_failure_rate_bps', null as any) + if (!trimmedValue && nullable) { + await saveChannelChange(key, null as any) return } - const failureRateBps = Number(trimmedValue) - if (!Number.isFinite(failureRateBps) || failureRateBps < 0 || failureRateBps > 10000) { + const parsedValue = Number(trimmedValue) + if (!Number.isInteger(parsedValue) || parsedValue < min || parsedValue > max) { + toast.error(t('error-update-channel')) + return + } + + await saveChannelChange(key, parsedValue as any) +} + +async function saveAutoPauseFailureRate(value: string) { + await saveIntegerField('auto_pause_failure_rate_bps', value, 0, 10000, true) +} + +async function saveAutoPauseConfidence(value: string) { + const confidence = Number(value.trim()) + if (!Number.isFinite(confidence) || confidence <= 0 || confidence >= 1) { toast.error(t('error-update-channel')) return } - await saveChannelChange('auto_pause_failure_rate_bps', Math.round(failureRateBps) as any) + await saveChannelChange('auto_pause_confidence', Number(confidence.toFixed(4)) as any) } async function rollbackRollout() { @@ -868,46 +882,74 @@ async function copyCurlCommand() {
- -
- - % -
-
- -
-