diff --git a/cli/README.md b/cli/README.md index 8747d2bf11..e685cc5c22 100644 --- a/cli/README.md +++ b/cli/README.md @@ -240,6 +240,12 @@ Capgo never inspects external content. Add encryption for trustless security. npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production ``` +Upload directly as a progressive rollout target while keeping the channel's stable bundle as fallback: + +```bash +npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production --rollout 10 +``` + **Options:** | Param | Type | Description | @@ -247,6 +253,9 @@ npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel prod | **-a** | string | API key to link to your account | | **-p** | string | Path of the folder to upload, if not provided it will use the webDir set in capacitor.config | | **-c** | string | Channel to link to | +| **--rollout** | string | Set the uploaded bundle as this channel's rollout target at a percentage from 0 to 100 | +| **--rollout-percentage-bps** | string | Set the uploaded bundle rollout percentage in basis points from 0 to 10000 | +| **--rollout-cache-ttl-seconds** | string | Cloudflare rollout decision cache TTL in seconds | | **-e** | string | Link to external URL instead of upload to Capgo Cloud | | **--iv-session-key** | string | Set the IV and session key for bundle URL external | | **--s3-region** | string | Region for your S3 bucket | diff --git a/cli/skills/release-management/SKILL.md b/cli/skills/release-management/SKILL.md index 52e109cec9..59eb204f73 100644 --- a/cli/skills/release-management/SKILL.md +++ b/cli/skills/release-management/SKILL.md @@ -19,6 +19,7 @@ Use this skill for OTA update workflows in Capgo Cloud. - Alias: `u` - Example: `npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production` +- Progressive rollout example: `npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production --rollout 10` - Key behavior: - Bundle version must be greater than `0.0.0` and unique. - Deleted versions cannot be reused. @@ -29,6 +30,9 @@ Use this skill for OTA update workflows in Capgo Cloud. - Important options: - `-p, --path ` - `-c, --channel ` + - `--rollout ` + - `--rollout-percentage-bps ` + - `--rollout-cache-ttl-seconds ` - `-e, --external ` - `--iv-session-key ` - `-b, --bundle ` diff --git a/cli/src/api/channels.ts b/cli/src/api/channels.ts index 653b024482..2318672a9c 100644 --- a/cli/src/api/channels.ts +++ b/cli/src/api/channels.ts @@ -190,7 +190,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) @@ -255,7 +255,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 d939971ff0..c1a139f1a6 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() @@ -612,11 +612,36 @@ type LinkedChannelVersion = { name: string } | null +function getUploadRolloutPercentageBps(options: OptionsUpload) { + if (options.rollout == null && options.rolloutPercentageBps == null) + return undefined + + if (options.rolloutPercentageBps != null) + return options.rolloutPercentageBps + + return Math.round((options.rollout ?? 0) * 100) +} + +function formatRolloutPercentage(bps: number) { + return `${Number((bps / 100).toFixed(2))}%` +} + +async function getVersionIdForChannelUpdate(supabase: SupabaseType, apikey: string, appid: string, bundle: string) { + const { data: versionId } = await supabase + .rpc('get_app_versions', { apikey, name_version: bundle, appid }) + .single() + + if (!versionId) + uploadFail('Cannot get version id, cannot set channel') + + return versionId +} + // It is really important that this function never terminates the program, it should always return. 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) @@ -673,12 +698,7 @@ async function setVersionInChannel( localConfig: localConfigType, selfAssign?: boolean, ) { - const { data: versionId } = await supabase - .rpc('get_app_versions', { apikey, name_version: bundle, appid }) - .single() - - if (!versionId) - uploadFail('Cannot get version id, cannot set channel') + const versionId = await getVersionIdForChannelUpdate(supabase, apikey, appid, bundle) const apiAccess = await hasCliPermission(supabase, apikey, 'app.create_channel', { appId: appid }) @@ -707,6 +727,72 @@ async function setVersionInChannel( } } +async function setRolloutVersionInChannel( + supabase: SupabaseType, + apikey: string, + displayBundleUrl: boolean, + bundle: string, + channel: string, + appid: string, + localConfig: localConfigType, + rolloutPercentageBps: number, + rolloutCacheTtlSeconds?: number, + selfAssign?: boolean, +) { + const versionId = await getVersionIdForChannelUpdate(supabase, apikey, appid, bundle) + + const { data: apiAccess } = await supabase + .rpc('is_allowed_capgkey', { apikey, keymode: ['write', 'all'] }) + .single() + + if (!apiAccess) { + log.warn('The upload key is not allowed to set the rollout in the channel') + return + } + + const { data: existingChannel, error: channelError } = await supabase + .from('channels') + .select('id, version') + .eq('app_id', appid) + .eq('name', channel) + .single() + + if (channelError || !existingChannel) { + uploadFail(`Cannot set rollout, channel ${channel} must already exist with a stable bundle`) + } + if (!existingChannel.version) { + uploadFail(`Cannot set rollout, channel ${channel} needs a stable bundle before using progressive rollout`) + } + + const channelPayload: Database['public']['Tables']['channels']['Update'] = { + rollout_version: versionId, + rollout_percentage_bps: rolloutPercentageBps, + rollout_enabled: true, + rollout_paused_at: null, + rollout_pause_reason: null, + ...(selfAssign ? { allow_device_self_set: true } : {}), + } + if (rolloutCacheTtlSeconds != null) + channelPayload.rollout_cache_ttl_seconds = rolloutCacheTtlSeconds + + const { error: rolloutError, data } = await supabase + .from('channels') + .update(channelPayload) + .eq('app_id', appid) + .eq('name', channel) + .select('id') + .single() + + if (rolloutError) + uploadFail(`Cannot set rollout in channel ${formatError(rolloutError)}`) + + const bundleUrl = `${localConfig.hostWeb}/app/${appid}/channel/${data.id}` + log.info(`Set ${appid} channel ${channel} rollout target to @${bundle} (${formatRolloutPercentage(rolloutPercentageBps)})`) + + if (displayBundleUrl) + log.info(`Bundle url: ${bundleUrl}`) +} + export async function getDefaultUploadChannel(appId: string, supabase: SupabaseType, hostWeb: string) { const { error, data } = await supabase.from('apps') .select('default_upload_channel') @@ -835,6 +921,7 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl const defaultUploadChannel = options.channel ? null : await getDefaultUploadChannel(appid, supabase, localConfig.hostWeb) const channel = options.channel || defaultUploadChannel || 'production' + const rolloutPercentageBps = getUploadRolloutPercentageBps(options) if (options.verbose) log.info(`[Verbose] Target channel: ${channel}`) @@ -1237,9 +1324,16 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl } if (hasOrganizationPerm(permissions, OrganizationPerm.write)) { - if (options.verbose) - log.info(`[Verbose] Setting bundle ${bundle} to channel ${channel}...`) - await setVersionInChannel(supabase, apikey, !!options.bundleUrl, bundle, channel, userId, orgId, appid, localConfig, options.selfAssign) + if (rolloutPercentageBps != null) { + if (options.verbose) + log.info(`[Verbose] Setting bundle ${bundle} as rollout target for channel ${channel}...`) + await setRolloutVersionInChannel(supabase, apikey, !!options.bundleUrl, bundle, channel, appid, localConfig, rolloutPercentageBps, options.rolloutCacheTtlSeconds, options.selfAssign) + } + else { + if (options.verbose) + log.info(`[Verbose] Setting bundle ${bundle} to channel ${channel}...`) + await setVersionInChannel(supabase, apikey, !!options.bundleUrl, bundle, channel, userId, orgId, appid, localConfig, options.selfAssign) + } if (options.verbose) log.info(`[Verbose] Channel updated successfully`) @@ -1329,6 +1423,7 @@ function checkValidOptions(options: OptionsUpload) { const noKey = options.key === false const forceCrc32 = options.forceCrc32Checksum === true const hasEncryptionKey = (options.keyV2 || options.keyDataV2 || existsSync(baseKeyV2)) + const hasUploadRollout = options.rollout != null || options.rolloutPercentageBps != null if (options.ivSessionKey && !options.external) { uploadFail('You need to provide an external url if you want to use the --iv-session-key option') @@ -1371,6 +1466,21 @@ function checkValidOptions(options: OptionsUpload) { if (forceCrc32 && hasEncryptionKey && !noKey) { uploadFail('You cannot use --force-crc32-checksum when encryption is enabled. Remove the flag or disable encryption.') } + if (options.rollout != null && (!Number.isFinite(options.rollout) || options.rollout < 0 || options.rollout > 100)) { + uploadFail('Rollout percentage must be between 0 and 100') + } + if (options.rolloutPercentageBps != null && (!Number.isInteger(options.rolloutPercentageBps) || options.rolloutPercentageBps < 0 || options.rolloutPercentageBps > 10000)) { + uploadFail('Rollout percentage basis points must be between 0 and 10000') + } + if (options.rolloutCacheTtlSeconds != null && (!Number.isInteger(options.rolloutCacheTtlSeconds) || options.rolloutCacheTtlSeconds < 60 || options.rolloutCacheTtlSeconds > 31536000)) { + uploadFail('Rollout cache TTL seconds must be between 60 and 31536000') + } + if (hasUploadRollout && options.dryUpload) { + uploadFail('You cannot use --rollout with --dry-upload because dry upload does not update channels') + } + if (hasUploadRollout && options.deleteLinkedBundleOnUpload) { + uploadFail('You cannot use --rollout with --delete-linked-bundle-on-upload because rollout needs the stable channel bundle as fallback') + } } async function maybePromptStarCapgoRepo() { 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..1d290f55e7 100644 --- a/cli/src/channel/set.ts +++ b/cli/src/channel/set.ts @@ -50,6 +50,24 @@ export type { OptionsSetChannel } from '../schemas/channel' const disableAutoUpdatesPossibleOptions = ['major', 'minor', 'metadata', 'patch', 'none'] +function assertIntegerInRange(value: number, label: string, min: number, max: number) { + if (!Number.isFinite(value) || !Number.isInteger(value) || value < min || value > max) + throw new Error(`${label} must be an integer between ${min} and ${max}`) +} + +function assertOptionalIntegerInRange(value: number | null | undefined, label: string, min: number, max: number) { + if (value == null) + return + assertIntegerInRange(value, label, min, max) +} + +function assertOptionalConfidence(value: number | undefined) { + if (value == null) + return + if (!Number.isFinite(value) || value <= 0 || value >= 1) + throw new Error('Auto-pause confidence must be a number greater than 0 and less than 1') +} + export async function setChannelInternal(channel: string, appId: string, options: OptionsSetChannel, silent = false) { if (!silent) intro('Set channel') @@ -97,6 +115,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 +168,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 +203,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 +220,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 +334,120 @@ export async function setChannelInternal(channel: string, appId: string, options channelPayload.version = data.id } + 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 + if (!silent) + log.info(`Set ${appId} channel: ${channel} rollout target to @${rolloutBundle}`) + } + + if (rolloutPercentage != null) { + if (!Number.isFinite(rolloutPercentage) || rolloutPercentage < 0 || rolloutPercentage > 100) + throw new Error('Rollout percentage must be between 0 and 100') + } + const finalRolloutPercentageBps = rolloutPercentageBps ?? (rolloutPercentage == null ? undefined : Math.round(rolloutPercentage * 100)) + if (finalRolloutPercentageBps != null) { + assertIntegerInRange(finalRolloutPercentageBps, 'Rollout percentage basis points', 0, 10000) + 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 + } + + assertOptionalIntegerInRange(rolloutCacheTtlSeconds, 'Rollout cache TTL seconds', 60, 31536000) + assertOptionalIntegerInRange(autoPauseWindowMinutes, 'Auto-pause window minutes', 1, 10080) + assertOptionalIntegerInRange(autoPauseFailureRateBps, 'Auto-pause failure rate basis points', 0, 10000) + assertOptionalConfidence(autoPauseConfidence) + assertOptionalIntegerInRange(autoPauseMinAttempts, 'Auto-pause minimum attempts', 0, Number.MAX_SAFE_INTEGER) + assertOptionalIntegerInRange(autoPauseMinFailures, 'Auto-pause minimum failures', 0, Number.MAX_SAFE_INTEGER) + assertOptionalIntegerInRange(autoPauseCooldownMinutes, 'Auto-pause cooldown minutes', 0, 10080) + + 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 c2db4a1847..29ea199c7b 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -163,6 +163,9 @@ Example: npx @capgo/cli@latest bundle upload com.example.app --path ./dist --cha .option('-a, --apikey ', optionDescriptions.apikey) .option('-p, --path ', `Path of the folder to upload, if not provided it will use the webDir set in capacitor.config`) .option('-c, --channel ', `Channel to link to`) + .option('--rollout ', `Set the uploaded bundle as this channel's rollout target at a percentage from 0 to 100`, value => Number.parseFloat(value)) + .option('--rollout-percentage-bps ', `Set the uploaded bundle rollout percentage in basis points from 0 to 10000`, value => Number.parseInt(value, 10)) + .option('--rollout-cache-ttl-seconds ', `Cloudflare rollout decision cache TTL in seconds`, value => Number.parseInt(value, 10)) .option('-e, --external ', `Link to external URL instead of upload to Capgo Cloud`) .option('--iv-session-key ', `Set the IV and session key for bundle URL external`) .option('--s3-region ', `Region for your S3 bucket`) @@ -499,6 +502,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/init/updater.ts b/cli/src/init/updater.ts index 2658733c6d..1e567922c2 100644 --- a/cli/src/init/updater.ts +++ b/cli/src/init/updater.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' +import process from 'node:process' export const CAPGO_UPDATER_PACKAGE = '@capgo/capacitor-updater' @@ -58,17 +59,6 @@ function getDeclaredDependency(packageJsonPath: string, packageName: string) { function readInstalledPackageVersion(packageJsonPath: string, packageName: string): string | null { const projectDir = dirname(packageJsonPath) - try { - const requireFromProject = createRequire(join(projectDir, 'package.json')) - const resolvedPath = requireFromProject.resolve(`${packageName}/package.json`) - const packageJson = JSON.parse(readFileSync(resolvedPath, 'utf-8')) as { version?: unknown } - if (typeof packageJson.version === 'string') - return packageJson.version - } - catch { - // Fall through to direct node_modules lookup. - } - let currentDir = projectDir while (true) { const packagePath = join(currentDir, 'node_modules', packageName, 'package.json') @@ -89,6 +79,20 @@ function readInstalledPackageVersion(packageJsonPath: string, packageName: strin currentDir = parentDir } + if (!process.versions.pnp) + return null + + try { + const requireFromProject = createRequire(packageJsonPath) + const resolvedPackageJsonPath = requireFromProject.resolve(`${packageName}/package.json`) + const packageJson = JSON.parse(readFileSync(resolvedPackageJsonPath, 'utf-8')) as { version?: unknown } + if (typeof packageJson.version === 'string') + return packageJson.version + } + catch { + return null + } + return null } diff --git a/cli/src/mcp/server.ts b/cli/src/mcp/server.ts index 80a6b6d87f..bf7cb1a1c8 100644 --- a/cli/src/mcp/server.ts +++ b/cli/src/mcp/server.ts @@ -3,7 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod' import pack from '../../package.json' -import { addAppOptionsSchema, cleanupOptionsSchema, getStatsOptionsSchema, requestBuildOptionsSchema, starAllRepositoriesOptionsSchema, starRepoOptionsSchema, updateAppOptionsSchema, updateChannelOptionsSchema, uploadOptionsSchema } from '../schemas/sdk' +import { addAppOptionsSchema, cleanupOptionsSchema, getStatsOptionsSchema, requestBuildOptionsSchema, starAllRepositoriesOptionsSchema, starRepoOptionsSchema, updateAppOptionsSchema, updateChannelOptionsBaseSchema, updateChannelOptionsSchema, uploadOptionsSchema } from '../schemas/sdk' import { CapgoSDK } from '../sdk' import { findSavedKey } from '../utils' @@ -121,13 +121,16 @@ export async function startMcpServer(): Promise { server.tool( 'capgo_upload_bundle', 'Upload a new app bundle to Capgo Cloud for distribution', - uploadOptionsSchema.pick({ appId: true, path: true, bundle: true, channel: true, comment: true, minUpdateVersion: true, autoMinUpdateVersion: true, encrypt: true }).shape, - async ({ appId, path, bundle, channel, comment, minUpdateVersion, autoMinUpdateVersion, encrypt }) => { + uploadOptionsSchema.pick({ appId: true, path: true, bundle: true, channel: true, rollout: true, rolloutPercentageBps: true, rolloutCacheTtlSeconds: true, comment: true, minUpdateVersion: true, autoMinUpdateVersion: true, encrypt: true }).shape, + async ({ appId, path, bundle, channel, rollout, rolloutPercentageBps, rolloutCacheTtlSeconds, comment, minUpdateVersion, autoMinUpdateVersion, encrypt }) => { const result = await sdk.uploadBundle({ appId, path, bundle, channel, + rollout, + rolloutPercentageBps, + rolloutCacheTtlSeconds, comment, minUpdateVersion, autoMinUpdateVersion, @@ -334,9 +337,9 @@ 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 }) => { - const result = await sdk.updateChannel({ + updateChannelOptionsBaseSchema.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 payload = updateChannelOptionsSchema.parse({ appId, channelId, bundle, @@ -350,7 +353,27 @@ 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, }) + const result = await sdk.updateChannel(payload) if (!result.success) { return formatMcpError(result) } diff --git a/cli/src/schemas/bundle.ts b/cli/src/schemas/bundle.ts index 00e733467b..53297316a2 100644 --- a/cli/src/schemas/bundle.ts +++ b/cli/src/schemas/bundle.ts @@ -9,6 +9,9 @@ export const optionsUploadSchema = optionsBaseSchema.extend({ bundle: z.string().optional(), path: z.string().optional(), channel: z.string().optional(), + rollout: z.number().finite().min(0).max(100).optional(), + rolloutPercentageBps: z.number().int().min(0).max(10000).optional(), + rolloutCacheTtlSeconds: z.number().int().min(60).max(31536000).optional(), displayIvSession: z.boolean().optional(), external: z.string().optional(), key: z.boolean().optional(), diff --git a/cli/src/schemas/channel.ts b/cli/src/schemas/channel.ts index 629e8049cf..e887a69f6e 100644 --- a/cli/src/schemas/channel.ts +++ b/cli/src/schemas/channel.ts @@ -1,6 +1,21 @@ import { z } from 'zod' import { optionsBaseSchema } from './base' +function rejectConflictingBooleanGroup>(value: T, ctx: z.RefinementCtx, keys: Array) { + const selected = keys.filter(key => value[key] === true) + if (selected.length < 2) + return + + const first = String(selected[0]) + for (const key of selected.slice(1)) { + const current = String(key) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [current], + message: `"${first}" and "${current}" cannot be used together`, + }) + } +} // ============================================================================ // Channel Data Schema // ============================================================================ @@ -64,6 +79,29 @@ export const optionsSetChannelSchema = optionsBaseSchema.extend({ prod: z.boolean().optional(), packageJson: z.string().optional(), ignoreMetadataCheck: z.boolean().optional(), + rolloutBundle: z.string().optional(), + rolloutPercentage: z.number().finite().min(0).max(100).optional(), + rolloutPercentageBps: z.number().int().min(0).max(10000).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().int().min(60).max(31536000).optional(), + autoPauseEnabled: z.boolean().optional(), + autoPauseDisabled: z.boolean().optional(), + autoPauseWindowMinutes: z.number().int().min(1).max(10080).optional(), + autoPauseFailureRateBps: z.number().int().min(0).max(10000).nullable().optional(), + autoPauseConfidence: z.number().finite().gt(0).lt(1).optional(), + autoPauseMinAttempts: z.number().int().min(0).nullable().optional(), + autoPauseMinFailures: z.number().int().min(0).nullable().optional(), + autoPauseAction: z.enum(['pause', 'rollback', 'notify']).optional(), + autoPauseCooldownMinutes: z.number().int().min(0).max(10080).optional(), +}).superRefine((value, ctx) => { + rejectConflictingBooleanGroup(value, ctx, ['rolloutEnable', 'rolloutDisable']) + rejectConflictingBooleanGroup(value, ctx, ['rolloutPause', 'rolloutResume', 'rolloutRollback', 'rolloutPromote']) + rejectConflictingBooleanGroup(value, ctx, ['autoPauseEnabled', 'autoPauseDisabled']) }) export type OptionsSetChannel = z.infer diff --git a/cli/src/schemas/sdk.ts b/cli/src/schemas/sdk.ts index aeab007491..3a6471921b 100644 --- a/cli/src/schemas/sdk.ts +++ b/cli/src/schemas/sdk.ts @@ -1,6 +1,21 @@ import { z } from 'zod' import { buildCredentialsSchema } from './build' +function rejectConflictingBooleanGroup>(value: T, ctx: z.RefinementCtx, keys: Array) { + const selected = keys.filter(key => value[key] === true) + if (selected.length < 2) + return + + const first = String(selected[0]) + for (const key of selected.slice(1)) { + const current = String(key) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [current], + message: `"${first}" and "${current}" cannot be used together`, + }) + } +} // ============================================================================ // SDK Result Schema // ============================================================================ @@ -75,6 +90,9 @@ export const uploadOptionsSchema = z.object({ path: z.string(), bundle: z.string().optional(), channel: z.string().optional(), + rollout: z.number().finite().min(0).max(100).optional(), + rolloutPercentageBps: z.number().int().min(0).max(10000).optional(), + rolloutCacheTtlSeconds: z.number().int().min(60).max(31536000).optional(), apikey: z.string().optional(), external: z.string().optional(), encrypt: z.boolean().optional(), @@ -178,7 +196,7 @@ export const addChannelOptionsSchema = z.object({ export type AddChannelOptions = z.infer -export const updateChannelOptionsSchema = z.object({ +export const updateChannelOptionsBaseSchema = z.object({ channelId: z.string(), appId: z.string(), bundle: z.string().optional(), @@ -192,11 +210,36 @@ export const updateChannelOptionsSchema = z.object({ emulator: z.boolean().optional(), device: z.boolean().optional(), prod: z.boolean().optional(), + rolloutBundle: z.string().optional(), + rolloutPercentage: z.number().finite().min(0).max(100).optional(), + rolloutPercentageBps: z.number().int().min(0).max(10000).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().int().min(60).max(31536000).optional(), + autoPauseEnabled: z.boolean().optional(), + autoPauseDisabled: z.boolean().optional(), + autoPauseWindowMinutes: z.number().int().min(1).max(10080).optional(), + autoPauseFailureRateBps: z.number().int().min(0).max(10000).nullable().optional(), + autoPauseConfidence: z.number().finite().gt(0).lt(1).optional(), + autoPauseMinAttempts: z.number().int().min(0).nullable().optional(), + autoPauseMinFailures: z.number().int().min(0).nullable().optional(), + autoPauseAction: z.enum(['pause', 'rollback', 'notify']).optional(), + autoPauseCooldownMinutes: z.number().int().min(0).max(10080).optional(), apikey: z.string().optional(), supaHost: z.string().optional(), supaAnon: z.string().optional(), }) +export const updateChannelOptionsSchema = updateChannelOptionsBaseSchema.superRefine((value, ctx) => { + rejectConflictingBooleanGroup(value, ctx, ['rolloutEnable', 'rolloutDisable']) + rejectConflictingBooleanGroup(value, ctx, ['rolloutPause', 'rolloutResume', 'rolloutRollback', 'rolloutPromote']) + rejectConflictingBooleanGroup(value, ctx, ['autoPauseEnabled', 'autoPauseDisabled']) +}) + export type UpdateChannelOptions = z.infer // ============================================================================ diff --git a/cli/src/sdk.ts b/cli/src/sdk.ts index 1f32321e56..6b85357a34 100644 --- a/cli/src/sdk.ts +++ b/cli/src/sdk.ts @@ -502,6 +502,9 @@ export class CapgoSDK { path: options.path, bundle: options.bundle, channel: options.channel, + rollout: options.rollout, + rolloutPercentageBps: options.rolloutPercentageBps, + rolloutCacheTtlSeconds: options.rolloutCacheTtlSeconds, external: options.external, key: options.encrypt !== false, // default true unless explicitly false keyV2: options.encryptionKey, @@ -809,6 +812,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 8de1d216d6..4471e30d68 100644 --- a/cli/src/types/supabase.types.ts +++ b/cli/src/types/supabase.types.ts @@ -268,6 +268,8 @@ export type Database = { ios_store_url: string | null 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 @@ -295,6 +297,8 @@ export type Database = { ios_store_url?: string | null 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 @@ -322,6 +326,8 @@ export type Database = { ios_store_url?: string | null 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 @@ -713,6 +719,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 @@ -735,6 +758,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 @@ -757,6 +797,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 @@ -769,6 +826,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"] @@ -2666,6 +2730,7 @@ export type Database = { Row: { action: Database["public"]["Enums"]["version_action"] app_id: string + channel_name: string | null timestamp: string version_id: number | null version_name: string | null @@ -2673,6 +2738,7 @@ export type Database = { Insert: { action: Database["public"]["Enums"]["version_action"] app_id: string + channel_name?: string | null timestamp?: string version_id?: number | null version_name?: string | null @@ -2680,6 +2746,7 @@ export type Database = { Update: { action?: Database["public"]["Enums"]["version_action"] app_id?: string + channel_name?: string | null timestamp?: string version_id?: number | null version_name?: string | null @@ -4156,7 +4223,7 @@ export type Database = { }[] } read_version_usage: { - Args: { p_app_id: string; p_period_end: string; p_period_start: string } + Args: { p_app_id: string; p_channel_name?: string | null; p_period_end: string; p_period_start: string } Returns: { app_id: string date: string diff --git a/cli/src/utils.ts b/cli/src/utils.ts index f3f6c4cb2c..7aeba6c91f 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -1930,7 +1930,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() @@ -1973,7 +1973,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/cli/test/test-upload-validation.mjs b/cli/test/test-upload-validation.mjs index 98e5565b7f..dac3a1f08e 100755 --- a/cli/test/test-upload-validation.mjs +++ b/cli/test/test-upload-validation.mjs @@ -5,6 +5,7 @@ */ import { canParse } from '@std/semver' +import { optionsUploadSchema } from '../src/schemas/bundle.ts' // This is the actual regex from utils.ts line 40 const regexSemver = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-z-][0-9a-z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-z-][0-9a-z-]*))*))?(?:\+([0-9a-z-]+(?:\.[0-9a-z-]+)*))?$/i @@ -69,6 +70,40 @@ for (const version of shouldPass) { } } +console.log('✓ Testing rollout upload options...\n') + +const validRolloutOptions = optionsUploadSchema.safeParse({ + apikey: 'test-key', + rollout: 12.5, + rolloutPercentageBps: 1250, + rolloutCacheTtlSeconds: 3600, +}) +if (!validRolloutOptions.success) { + console.error(' ❌ VALIDATION FAILURE!') + console.error(' Upload rollout options should accept percentage, basis points, and cache TTL') + allPassed = false +} +else { + console.log(' ✅ Rollout upload options are accepted\n') +} + +const invalidRolloutOptions = [ + { apikey: 'test-key', rollout: -1 }, + { apikey: 'test-key', rollout: 100.1 }, + { apikey: 'test-key', rolloutPercentageBps: 10001 }, + { apikey: 'test-key', rolloutCacheTtlSeconds: 59 }, +] +for (const options of invalidRolloutOptions) { + const result = optionsUploadSchema.safeParse(options) + if (result.success) { + console.error(` ❌ VALIDATION FAILURE! Expected rejection for ${JSON.stringify(options)}`) + allPassed = false + } +} + +if (allPassed) + console.log(' ✅ Invalid rollout upload options are rejected\n') + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') if (allPassed) { diff --git a/docs/pr/2053/rollout-disabled-desktop-v3.png b/docs/pr/2053/rollout-disabled-desktop-v3.png new file mode 100644 index 0000000000..5140ea3110 Binary files /dev/null and b/docs/pr/2053/rollout-disabled-desktop-v3.png differ diff --git a/docs/pr/2053/rollout-disabled-mobile-v3.png b/docs/pr/2053/rollout-disabled-mobile-v3.png new file mode 100644 index 0000000000..815f5d7a55 Binary files /dev/null and b/docs/pr/2053/rollout-disabled-mobile-v3.png differ diff --git a/docs/pr/2053/rollout-enabled-desktop-v3.png b/docs/pr/2053/rollout-enabled-desktop-v3.png new file mode 100644 index 0000000000..eda083dd50 Binary files /dev/null and b/docs/pr/2053/rollout-enabled-desktop-v3.png differ diff --git a/messages/en.json b/messages/en.json index a2f2d173e4..a86be6176a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1938,5 +1938,28 @@ "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-target": "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", + "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/components/tables/BundleTable.vue b/src/components/tables/BundleTable.vue index 0bc8e7304b..8c333366ec 100644 --- a/src/components/tables/BundleTable.vue +++ b/src/components/tables/BundleTable.vue @@ -335,7 +335,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) @@ -508,7 +508,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 0e8ef8e315..c3d41e57a5 100644 --- a/src/pages/app/[app].channel.[channel].vue +++ b/src/pages/app/[app].channel.[channel].vue @@ -3,14 +3,13 @@ import type { Database } from '~/types/supabase.types' import { FormKit } from '@formkit/vue' import { greaterOrEqual, parse } from '@std/semver' import { computedAsync, onClickOutside } from '@vueuse/core' -import { ref, watchEffect } from 'vue' +import { computed, ref, watchEffect } from 'vue' import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import { toast } from 'vue-sonner' import IconCopy from '~icons/heroicons/clipboard-document-check' import IconCode from '~icons/heroicons/code-bracket' import Settings from '~icons/heroicons/cog-8-tooth' -import IconEye from '~icons/heroicons/eye' import IconInformation from '~icons/heroicons/information-circle' import IconSearch from '~icons/ic/round-search?raw' import IconAlertCircle from '~icons/lucide/alert-circle' @@ -27,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'] @@ -39,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]') @@ -58,12 +73,46 @@ const packageId = ref('') const id = ref(0) const loading = ref(true) const channel = ref() +const rolloutConfigured = computed(() => !!channel.value?.rollout_version) +const rolloutPercentage = computed(() => (channel.value?.rollout_percentage_bps ?? 0) / 100) +const rolloutStatusLabel = computed(() => { + if (!rolloutConfigured.value) + return t('not-configured') + if (channel.value?.rollout_paused_at) + return t('paused') + return channel.value?.rollout_enabled ? t('enabled') : t('disabled') +}) +const rolloutStatusClass = computed(() => { + if (!rolloutConfigured.value || !channel.value?.rollout_enabled) { + return 'border-slate-300 bg-slate-50 text-slate-700 dark:border-slate-700 dark:bg-slate-900/40 dark:text-slate-300' + } + if (channel.value.rollout_paused_at) { + return 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-800/70 dark:bg-amber-950/30 dark:text-amber-200' + } + return 'border-sky-300 bg-sky-50 text-sky-800 dark:border-sky-800/70 dark:bg-sky-950/30 dark:text-sky-200' +}) +const rolloutPercentageText = computed(() => `${rolloutPercentage.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}%`) +const rolloutProgressClass = computed(() => { + if (!rolloutConfigured.value || !channel.value?.rollout_enabled) + return 'bg-slate-300 dark:bg-slate-600' + if (channel.value.rollout_paused_at) + return 'bg-amber-500 dark:bg-amber-400' + return 'bg-sky-500 dark:bg-sky-400' +}) +const rolloutProgressStyle = computed(() => { + const percentage = Math.max(0, Math.min(100, rolloutPercentage.value)) + return `width: ${percentage}%` +}) +const showRolloutSettings = computed(() => !!channel.value?.rollout_enabled || !!channel.value?.rollout_paused_at) const canUpdateChannelSettings = computedAsync(async () => { if (!packageId.value) return false return await checkPermissions('channel.update_settings', { appId: packageId.value }) }, false) +const rolloutControlsDisabled = computed(() => !canUpdateChannelSettings.value) +const rolloutActionsDisabled = computed(() => rolloutControlsDisabled.value || !rolloutConfigured.value) +const rolloutPauseDisabled = computed(() => rolloutActionsDisabled.value || !channel.value?.rollout_enabled) const canPromoteBundle = computedAsync(async () => { if (!id.value) @@ -85,12 +134,6 @@ function openBundle() { router.push(`/app/${route.params.app}/bundle/${channel.value.version.id}`) } -function openPreview() { - if (!channel.value) - return - router.push(`/app/${route.params.app}/channel/${id.value}/preview`) -} - async function getChannel(force = false) { if (!id.value) return @@ -112,7 +155,7 @@ async function getChannel(force = false) { name, public, owner_org, - version ( + version:app_versions!channels_version_fkey( id, name, app_id, @@ -122,6 +165,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, @@ -157,8 +220,9 @@ async function getChannel(force = false) { } } -async function saveChannelChange(key: K, val: ChannelUpdate[K]) { - const canUpdate = key === 'version' +async function saveChannelChanges(update: ChannelUpdate) { + const changesStableVersion = Object.prototype.hasOwnProperty.call(update, 'version') + const canUpdate = changesStableVersion ? canPromoteBundle.value : canUpdateChannelSettings.value @@ -170,17 +234,13 @@ async function saveChannelChange(key: K, val: Chan if (!id.value || !channel.value) return false - // Validate version ID if updating version field - if (key === 'version' && (val === undefined || val === null || typeof val !== 'number')) { - console.error('Invalid version ID:', val) + if (Object.prototype.hasOwnProperty.call(update, 'version') && (update.version === undefined || update.version === null || typeof update.version !== 'number')) { + console.error('Invalid version ID:', update.version) toast.error(t('error-invalid-version')) return false } try { - const update = { - [key]: val, - } as ChannelUpdate const { error } = await supabase .from('channels') .update(update) @@ -190,11 +250,10 @@ async function saveChannelChange(key: K, val: Chan console.error('no channel update', error) return false } - else { - await getChannel(true) - toast.info(t('cloud-replication-delay')) - return true - } + + await getChannel(true) + toast.info(t('cloud-replication-delay')) + return true } catch (error) { console.error(error) @@ -202,6 +261,10 @@ async function saveChannelChange(key: K, val: Chan } } +async function saveChannelChange(key: K, val: ChannelUpdate[K]) { + return await saveChannelChanges({ [key]: val } as ChannelUpdate) +} + watchEffect(async () => { if (route.path.includes('/channel/')) { loading.value = true @@ -263,8 +326,18 @@ async function handleVersionLink(appVersion: Database['public']['Tables']['app_v else { toast.info(t('bundle-compatible-with-channel', { channel: channel.value.name })) } - await saveChannelChange('version', appVersion.id) - toast.success(t('linked-bundle')) + if (bundleLinkMode.value === 'rollout') { + const saved = await saveChannelChanges({ + rollout_version: appVersion.id, + rollout_enabled: true, + }) + if (saved) + toast.success(t('rollout-target-linked')) + return + } + + if (await saveChannelChange('version', appVersion.id)) + toast.success(t('linked-bundle')) } async function getUnknownVersion(): Promise { @@ -400,6 +473,84 @@ 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 saveIntegerField(key: EditableChannelKey, value: string, min: number, max: number, nullable = false) { + const trimmedValue = value.trim() + if (!trimmedValue && nullable) { + await saveChannelChange(key, null as any) + return + } + + 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_confidence', Number(confidence.toFixed(4)) as any) +} + +async function rollbackRollout() { + await saveChannelChanges({ + rollout_version: null, + rollout_enabled: false, + rollout_percentage_bps: 0, + rollout_paused_at: null, + rollout_pause_reason: null, + }) +} + +async function promoteRollout() { + if (!channel.value?.rollout_version) + return + await saveChannelChanges({ + version: channel.value.rollout_version, + rollout_version: null, + rollout_enabled: false, + rollout_percentage_bps: 0, + rollout_paused_at: null, + rollout_pause_reason: null, + }) +} + +async function toggleRolloutPause() { + await saveChannelChanges(channel.value?.rollout_paused_at + ? { rollout_paused_at: null, rollout_pause_reason: null } + : { rollout_paused_at: new Date().toISOString(), rollout_pause_reason: t('manual-rollout-pause') }) +} + async function refreshFilteredVersions() { if (!channel.value) return @@ -672,20 +823,11 @@ async function copyCurlCommand() {
{{ channel.version.name }} - @@ -715,6 +857,230 @@ async function copyCurlCommand() { {{ channel.version.comment }} +
+
+
+
+
+

+ {{ t('progressive-rollout') }} +

+

+ {{ channel.rollout_pause_reason }} +

+
+ + {{ rolloutStatusLabel }} + +
+ +
+
+
+ {{ t('rollout-target') }} +
+
+ {{ channel?.rollout_version_info?.name ?? t('not-configured') }} +
+
+
+
+ {{ t('rollout-percentage') }} +
+
+ {{ rolloutPercentageText }} +
+
+
+
+ {{ t('cache-ttl-seconds') }} +
+
+ {{ channel.rollout_cache_ttl_seconds }} +
+
+
+
+ +
+
+
+
+ +
+
+ + +
+ +
+ + + + + +
+
+
+ +
+
+

+ {{ t('auto-pause') }} +

+ +
+ +
+ + + + + + + +
+
+
+
- {{ 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 fa791d47e7..b252f81676 100644 --- a/src/services/supabase.ts +++ b/src/services/supabase.ts @@ -560,8 +560,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) @@ -570,7 +570,7 @@ export async function getRemoteDependencies(appId: string, channel: string) { if (error) { throw new Error(error.message) } - return convertNativePackages((remoteNativePackages.version.native_packages as any) ?? []) + return convertNativePackages((remoteNativePackages?.version?.native_packages as any) ?? []) } interface Compatibility { diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index de54cd2661..07d9e70484 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -268,6 +268,8 @@ export type Database = { ios_store_url: string | null 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 @@ -295,6 +297,8 @@ export type Database = { ios_store_url?: string | null 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 @@ -322,6 +326,8 @@ export type Database = { ios_store_url?: string | null 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 @@ -713,6 +719,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 @@ -735,6 +758,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 @@ -757,6 +797,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 @@ -769,6 +826,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"] @@ -2717,6 +2781,8 @@ export type Database = { Row: { action: Database["public"]["Enums"]["version_action"] app_id: string + channel_id: number | null + channel_name: string | null timestamp: string version_id: number | null version_name: string | null @@ -2724,6 +2790,8 @@ export type Database = { Insert: { action: Database["public"]["Enums"]["version_action"] app_id: string + channel_id?: number | null + channel_name?: string | null timestamp?: string version_id?: number | null version_name?: string | null @@ -2731,6 +2799,8 @@ export type Database = { Update: { action?: Database["public"]["Enums"]["version_action"] app_id?: string + channel_id?: number | null + channel_name?: string | null timestamp?: string version_id?: number | null version_name?: string | null @@ -4257,7 +4327,7 @@ export type Database = { }[] } read_version_usage: { - Args: { p_app_id: string; p_period_end: string; p_period_start: string } + Args: { p_app_id: string; p_channel_id?: number | null; p_channel_name?: string | null; p_period_end: string; p_period_start: string } Returns: { app_id: string date: string diff --git a/supabase/functions/_backend/plugins/stats.ts b/supabase/functions/_backend/plugins/stats.ts index cb4a25951d..4b72b0c291 100644 --- a/supabase/functions/_backend/plugins/stats.ts +++ b/supabase/functions/_backend/plugins/stats.ts @@ -8,7 +8,7 @@ import { getAppStatus, setAppStatus } from '../utils/appStatus.ts' import { BRES, simpleError, simpleError200, simpleRateLimit } from '../utils/hono.ts' import { cloudlog } from '../utils/logging.ts' import { sendNotifOrgCached } from '../utils/notifications.ts' -import { closeClient, ensurePlaceholderVersions, getAppOwnerPostgres, getAppVersionPostgres, getDrizzleClient, getPgClient } from '../utils/pg.ts' +import { closeClient, ensurePlaceholderVersions, getAppOwnerPostgres, getAppVersionPostgres, getDrizzleClient, getEffectiveDeviceChannelNamePostgres, getPgClient } from '../utils/pg.ts' import { makeDevice, parsePluginBody } from '../utils/plugin_parser.ts' import { statsRequestSchema } from '../utils/plugin_validation.ts' import { createStatsMau, createStatsVersion, onPremStats, sendStatsAndDevice } from '../utils/stats.ts' @@ -32,6 +32,11 @@ interface PostResult { moreInfo?: Record } +function normalizeStatsChannelName(channelName: string | null | undefined): string | null { + const trimmed = channelName?.trim() + return trimmed || null +} + async function post(c: Context, drizzleClient: ReturnType, body: AppStats): Promise { const { app_id, action, version_name, old_version_name, plugin_version, metadata } = body @@ -86,6 +91,12 @@ async function post(c: Context, drizzleClient: ReturnType | undefined + const getEffectiveStatsChannel = () => { + effectiveStatsChannelPromise ??= getEffectiveDeviceChannelNamePostgres(c, app_id, device.device_id, normalizeStatsChannelName(device.default_channel), device.platform, appOwner.channel_device_count > 0, drizzleClient as ReturnType) + return effectiveStatsChannelPromise + } + // Extract version from composite format if present (e.g., "1.2.3:main.js" -> "1.2.3") // Composite format is used for file-specific failure stats const colonIndex = version_name.indexOf(':') @@ -109,12 +120,12 @@ async function post(c: Context, drizzleClient: ReturnType) if (oldVersion && oldVersion.id !== appVersion.id) { - await createStatsVersion(c, old_version_name, app_id, 'uninstall') + await createStatsVersion(c, old_version_name, app_id, 'uninstall', await getEffectiveStatsChannel()) statsActions.push({ action: 'uninstall', versionName: old_version_name ?? 'unknown' }) } } @@ -125,9 +136,9 @@ async function post(c: Context, drizzleClient: ReturnType { const { data: channelData, error: channelError } = await supabase .from('channels') - .select('id, name, version, updated_at, version (name)') + .select('id, name, 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..80a503f58f 100644 --- a/supabase/functions/_backend/public/channel/post.ts +++ b/supabase/functions/_backend/public/channel/post.ts @@ -22,6 +22,84 @@ interface ChannelSet { allow_device?: boolean allow_dev?: boolean allow_prod?: boolean + rolloutVersion?: string | number | null + rollout_version?: string | number | null + rolloutPercentage?: number + rollout_percentage?: number + rolloutPercentageBps?: number + rollout_percentage_bps?: number + rolloutEnabled?: boolean + rollout_enabled?: boolean + rolloutPaused?: boolean + rollout_paused?: boolean + rolloutPausedAt?: string | null + rollout_paused_at?: string | null + rolloutPauseReason?: string | null + rollout_pause_reason?: string | null + rolloutCacheTtlSeconds?: number + rollout_cache_ttl_seconds?: number + rollback?: boolean + promoteToStable?: boolean + promote_to_stable?: boolean + autoPauseEnabled?: boolean + auto_pause_enabled?: boolean + autoPauseWindowMinutes?: number + auto_pause_window_minutes?: number + autoPauseFailureRateBps?: number | null + auto_pause_failure_rate_bps?: number | null + autoPauseConfidence?: number + auto_pause_confidence?: number + autoPauseMinAttempts?: number | null + auto_pause_min_attempts?: number | null + autoPauseMinFailures?: number | null + auto_pause_min_failures?: number | null + autoPauseAction?: 'pause' | 'rollback' | 'notify' + auto_pause_action?: 'pause' | 'rollback' | 'notify' + autoPauseCooldownMinutes?: number + auto_pause_cooldown_minutes?: number +} + +function definedOrAlias(value: T | undefined, alias: T | undefined): T | undefined { + return value === undefined ? alias : value +} + +function normalizeChannelSet(body: ChannelSet): ChannelSet { + return { + ...body, + rolloutVersion: definedOrAlias(body.rolloutVersion, body.rollout_version), + rolloutPercentage: definedOrAlias(body.rolloutPercentage, body.rollout_percentage), + rolloutPercentageBps: definedOrAlias(body.rolloutPercentageBps, body.rollout_percentage_bps), + rolloutEnabled: definedOrAlias(body.rolloutEnabled, body.rollout_enabled), + rolloutPaused: definedOrAlias(body.rolloutPaused, body.rollout_paused), + rolloutPausedAt: definedOrAlias(body.rolloutPausedAt, body.rollout_paused_at), + rolloutPauseReason: definedOrAlias(body.rolloutPauseReason, body.rollout_pause_reason), + rolloutCacheTtlSeconds: definedOrAlias(body.rolloutCacheTtlSeconds, body.rollout_cache_ttl_seconds), + promoteToStable: definedOrAlias(body.promoteToStable, body.promote_to_stable), + autoPauseEnabled: definedOrAlias(body.autoPauseEnabled, body.auto_pause_enabled), + autoPauseWindowMinutes: definedOrAlias(body.autoPauseWindowMinutes, body.auto_pause_window_minutes), + autoPauseFailureRateBps: definedOrAlias(body.autoPauseFailureRateBps, body.auto_pause_failure_rate_bps), + autoPauseConfidence: definedOrAlias(body.autoPauseConfidence, body.auto_pause_confidence), + autoPauseMinAttempts: definedOrAlias(body.autoPauseMinAttempts, body.auto_pause_min_attempts), + autoPauseMinFailures: definedOrAlias(body.autoPauseMinFailures, body.auto_pause_min_failures), + autoPauseAction: definedOrAlias(body.autoPauseAction, body.auto_pause_action), + autoPauseCooldownMinutes: definedOrAlias(body.autoPauseCooldownMinutes, body.auto_pause_cooldown_minutes), + } +} + +function validateIntegerRange(value: number | null | undefined, field: string, min: number, max: number) { + if (value == null) + return + if (!Number.isFinite(value) || !Number.isInteger(value) || value < min || value > max) { + throw simpleError('invalid_rollout_config', `${field} must be an integer between ${min} and ${max}`, { field, value, min, max }) + } +} + +function validateConfidence(value: number | undefined) { + if (value == null) + return + if (!Number.isFinite(value) || value <= 0 || value >= 1) { + throw simpleError('invalid_auto_pause_confidence', 'Auto-pause confidence must be greater than 0 and less than 1', { autoPauseConfidence: value }) + } } async function findVersion(c: Context, appID: string, version: string, ownerOrg: string, apikey: Database['public']['Tables']['apikeys']['Row']) { @@ -40,7 +118,30 @@ async function findVersion(c: Context, appID: string, version: string, ownerOrg: return data.id } +async function findVersionId(c: Context, appID: string, versionId: number, ownerOrg: string, apikey: Database['public']['Tables']['apikeys']['Row']) { + const { data, error: vError } = await supabaseApikey(c, apikey.key) + .from('app_versions') + .select('id') + .eq('id', versionId) + .eq('app_id', appID) + .eq('owner_org', ownerOrg) + .eq('deleted', false) + .single() + if (vError || !data) { + cloudlogErr({ requestId: c.get('requestId'), message: 'Cannot find version by id', data: { appID, versionId, ownerOrg, vError } }) + return Promise.reject(new Error(vError?.message ?? 'Cannot find version')) + } + return data.id +} + +function resolveVersion(c: Context, appID: string, version: string | number, ownerOrg: string, apikey: Database['public']['Tables']['apikeys']['Row']) { + return typeof version === 'number' + ? findVersionId(c, appID, version, ownerOrg, apikey) + : findVersion(c, appID, version, ownerOrg, apikey) +} + export async function post(c: Context, body: ChannelSet, apikey: Database['public']['Tables']['apikeys']['Row']): Promise { + body = normalizeChannelSet(body) if (!body.app_id) { throw simpleError('missing_app_id', 'Missing app_id', { body }) } @@ -56,6 +157,55 @@ 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) + if (body.rolloutPercentage != null && (!Number.isFinite(body.rolloutPercentage) || body.rolloutPercentage < 0 || body.rolloutPercentage > 100)) { + throw simpleError('invalid_rollout_percentage', 'Rollout percentage must be between 0 and 100', { rolloutPercentage: body.rolloutPercentage }) + } + const rolloutPercentageBps = body.rolloutPercentageBps ?? (body.rolloutPercentage == null ? undefined : Math.round(body.rolloutPercentage * 100)) + validateIntegerRange(rolloutPercentageBps, 'rolloutPercentageBps', 0, 10000) + validateIntegerRange(body.rolloutCacheTtlSeconds, 'rolloutCacheTtlSeconds', 60, 31536000) + validateIntegerRange(body.autoPauseWindowMinutes, 'autoPauseWindowMinutes', 1, 10080) + validateIntegerRange(body.autoPauseFailureRateBps, 'autoPauseFailureRateBps', 0, 10000) + validateConfidence(body.autoPauseConfidence) + validateIntegerRange(body.autoPauseMinAttempts, 'autoPauseMinAttempts', 0, Number.MAX_SAFE_INTEGER) + validateIntegerRange(body.autoPauseMinFailures, 'autoPauseMinFailures', 0, Number.MAX_SAFE_INTEGER) + validateIntegerRange(body.autoPauseCooldownMinutes, 'autoPauseCooldownMinutes', 0, 10080) + 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 changesRolloutTarget = body.rolloutVersion !== undefined || !!body.rollback || !!body.promoteToStable + const shouldLoadExistingChannel = body.version === undefined || changesRolloutTarget + let existingChannelVersion: number | null = null + let existingRolloutVersion: number | null = null + let existingChannelId: number | null = null + if (shouldLoadExistingChannel) { + const { data: existingChannel } = await supabaseApikey(c, apikey.key) + .from('channels') + .select('id, version, rollout_version') + .eq('app_id', body.app_id) + .eq('name', body.channel) + .maybeSingle() + existingChannelId = existingChannel?.id ?? null + existingChannelVersion = existingChannel?.version ?? null + existingRolloutVersion = existingChannel?.rollout_version ?? null + } + if (changesRolloutTarget) { + if (existingChannelId === null) { + throw simpleError('cannot_find_channel', 'Cannot find channel', { app_id: body.app_id, channel: body.channel }) + } + if (!(await checkPermission(c, 'channel.promote_bundle', { appId: body.app_id, channelId: existingChannelId }))) { + throw simpleError('cannot_promote_bundle', 'You can\'t promote bundles on this channel', { app_id: body.app_id, channel: body.channel, channelId: existingChannelId }) + } + } + if (body.rolloutVersion && body.version === undefined && existingChannelVersion === null) { + throw simpleError('missing_stable_version', 'Cannot set rollout target without a stable bundle', { app_id: body.app_id, channel: body.channel }) + } + if (body.rolloutVersion && body.version === 'unknown') { + throw simpleError('missing_stable_version', 'Cannot set rollout target without a stable bundle', { app_id: body.app_id, channel: body.channel }) + } + const rolloutPausedAt = body.rolloutPausedAt !== undefined + ? body.rolloutPausedAt + : body.rolloutPaused == null ? undefined : body.rolloutPaused ? new Date().toISOString() : null + const channel: Database['public']['Tables']['channels']['Insert'] = { created_by: apikey.user_id, app_id: body.app_id, @@ -71,11 +221,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 }), + ...(rolloutPausedAt === undefined ? {} : { rollout_paused_at: rolloutPausedAt, ...(rolloutPausedAt ? {} : { 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 resolveVersion(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..52754006cb --- /dev/null +++ b/supabase/functions/_backend/triggers/cron_rollout_auto_pause.ts @@ -0,0 +1,185 @@ +import type { AutoPauseAction } from '../utils/rollout.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 + 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 + rollout_paused_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 = createHono('', version) + +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 updateChannelOrThrow(supabase: ReturnType, channelId: number, patch: Record) { + const { error } = await supabase + .from('channels') + .update(patch as any) + .eq('id', channelId) + + if (error) + throw error +} + +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(), { id: channel.id, name: channel.name }) + 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 updateChannelOrThrow(supabase, channel.id, { auto_pause_last_checked_at: now.toISOString() }) + 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 updateChannelOrThrow(supabase, channel.id, { + ...basePatch, + rollout_paused_at: now.toISOString(), + }) + } + else if (result.action === 'rollback') { + await updateChannelOrThrow(supabase, channel.id, { + ...basePatch, + rollout_enabled: false, + rollout_percentage_bps: 0, + rollout_version: null, + rollout_paused_at: null, + }) + } + else { + await updateChannelOrThrow(supabase, channel.id, basePatch) + } + + 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_paused_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) + .is('rollout_paused_at', 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/cloudflare.ts b/supabase/functions/_backend/utils/cloudflare.ts index d73a584233..06fe76b0fe 100644 --- a/supabase/functions/_backend/utils/cloudflare.ts +++ b/supabase/functions/_backend/utils/cloudflare.ts @@ -2,7 +2,7 @@ import type { AnalyticsEngineDataPoint, D1Database, Hyperdrive } from '@cloudfla import type { Context } from 'hono' import type { DeviceComparable } from './deviceComparison.ts' import type { Database } from './supabase.types.ts' -import type { DeviceRes, DeviceWithoutCreatedAt, NativeVersionUsage, ReadDevicesParams, ReadStatsParams, StatsMetadata, VersionUsage } from './types.ts' +import type { DeviceRes, DeviceWithoutCreatedAt, NativeVersionUsage, ReadDevicesParams, ReadStatsParams, StatsMetadata, VersionUsage, VersionUsageChannel } from './types.ts' import dayjs from 'dayjs' import { CacheHelper } from './cache.ts' import { hasComparableDeviceChanged, toComparableDevice } from './deviceComparison.ts' @@ -159,12 +159,15 @@ export function trackBandwidthUsageCF(c: Context, device_id: string, app_id: str return Promise.resolve() } -export function trackVersionUsageCF(c: Context, version_name: string, app_id: string, action: string) { +export function trackVersionUsageCF(c: Context, version_name: string, app_id: string, action: string, channel?: VersionUsageChannel | string | null) { if (!c.env.VERSION_USAGE) return Promise.resolve() + const channelName = typeof channel === 'string' ? channel : channel?.name + const channelId = typeof channel === 'object' && channel?.id ? String(channel.id) : '' + c.env.VERSION_USAGE.writeDataPoint({ - blobs: [app_id, version_name, action], + blobs: [app_id, version_name, action, channelName ?? '', channelId], indexes: [app_id], }) @@ -518,11 +521,18 @@ interface StoreApp { developer_id?: string // Optional as it's not NOT NULL } -export async function readStatsVersionCF(c: Context, app_id: string, period_start: string, period_end: string): Promise { +export async function readStatsVersionCF(c: Context, app_id: string, period_start: string, period_end: string, channel?: VersionUsageChannel | string): Promise { if (!c.env.VERSION_USAGE) return [] - // Note: blob2 contains version_name for new data and version_id (numeric) for old data - // The cron job handles backwards compatibility by detecting numeric values + // Note: blob2 contains version_name for new data and version_id (numeric) for old data. + // blob4 contains channel_name and blob5 contains channel_id only for newer data. + const channelId = typeof channel === 'object' && channel?.id ? String(channel.id) : '' + const channelName = typeof channel === 'string' ? channel : channelId ? null : channel?.name + const safeChannelName = channelName ? escapeSqlString(channelName) : '' + const safeChannelId = channelId ? escapeSqlString(channelId) : '' + const channelFilter = safeChannelId + ? `AND blob5 = '${safeChannelId}'` + : safeChannelName ? `AND blob4 = '${safeChannelName}'` : '' const query = `SELECT blob1 as app_id, blob2 as version_name, @@ -536,6 +546,7 @@ WHERE app_id = '${escapeSqlString(app_id)}' AND timestamp >= toDateTime('${formatDateCF(period_start)}') AND timestamp < toDateTime('${formatDateCF(period_end)}') + ${channelFilter} GROUP BY date, app_id, version_name ORDER BY date` diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index 3b26ab4997..97493feb0d 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -12,6 +12,7 @@ import { getClientDbRegionSB } from './geolocation.ts' import { cloudlog, cloudlogErr } from './logging.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 @@ -437,31 +438,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, @@ -477,6 +485,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( @@ -485,21 +500,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, @@ -536,6 +554,73 @@ export function requestInfosChannelDevicePostgres( return channelDevice.then(data => data.at(0)) } +export async function getEffectiveDeviceChannelNamePostgres( + c: Context, + app_id: string, + device_id: string, + fallbackChannelName: string | null | undefined, + platform: string, + hasChannelDeviceOverrides: boolean, + drizzleClient: ReturnType, +) { + const fallback = typeof fallbackChannelName === 'string' && fallbackChannelName.trim() !== '' + ? fallbackChannelName.trim() + : null + const { channelDevicesAlias, channelAlias } = getAlias() + + if (hasChannelDeviceOverrides) { + const channelQuery = drizzleClient + .select({ id: channelAlias.id, name: channelAlias.name }) + .from(channelDevicesAlias) + .innerJoin(channelAlias, and(eq(channelDevicesAlias.channel_id, channelAlias.id), eq(channelAlias.app_id, app_id))) + .where(and(eq(channelDevicesAlias.device_id, device_id), eq(channelDevicesAlias.app_id, app_id))) + .limit(1) + + cloudlog({ requestId: c.get('requestId'), message: 'stats channel override Query:', channelQuery: channelQuery.toSQL() }) + const channel = await channelQuery.then(data => data.at(0)) + if (channel?.name) + return channel + } + + const platformQuery = platform === 'android' ? channelAlias.android : platform === 'electron' ? channelAlias.electron : channelAlias.ios + const getChannelByName = async (channelName: string | null) => { + const channelQuery = drizzleClient + .select({ id: channelAlias.id, name: channelAlias.name }) + .from(channelAlias) + .where( + channelName + ? and( + eq(channelAlias.app_id, app_id), + eq(channelAlias.name, channelName), + 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), + ), + ) + .orderBy(channelAlias.name, channelAlias.id) + .limit(1) + + cloudlog({ requestId: c.get('requestId'), message: 'stats channel Query:', channelQuery: channelQuery.toSQL(), fallbackChannelName: channelName }) + const channel = await channelQuery.then(data => data.at(0)) + return channel?.name ? channel : null + } + + if (fallback) { + const channelName = await getChannelByName(fallback) + if (channelName) + return channelName + } + + return getChannelByName(null) +} + export function requestInfosChannelPostgres( c: Context, platform: string, @@ -586,6 +671,149 @@ 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) + 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, + rolloutVersion: rolloutVersionSelect, + channels: channelSelect, + }) + .from(channelAlias) + .innerJoin(versionAlias, activeChannelVersionJoin(channelAlias.version, versionAlias)) + .leftJoin(rolloutVersionAlias, activeChannelVersionJoin(channelAlias.rollout_version, rolloutVersionAlias, channelAlias.app_id)) + .where(channelFilter) + .orderBy(channelAlias.name, channelAlias.id) + .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, @@ -593,24 +821,53 @@ export function requestInfosPostgres( device_id: string, defaultChannel: string, drizzleClient: ReturnType, - channelDeviceCount?: number | null, - manifestBundleCount?: number | null, + 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 isPausedRolloutVersion = Array.isArray(rolloutPausedVersionNames) && rolloutPausedVersionNames.includes(currentVersionName) + const shouldUseRolloutPath = (rolloutChannelCount ?? 0) > 0 || isPausedRolloutVersion + + 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 @@ -623,6 +880,8 @@ export interface AppOwnerPostgresResult { plan_valid: boolean 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 } @@ -645,6 +904,8 @@ 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, + 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 487de5c7d9..1b1d186dbe 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 @@ -30,6 +30,8 @@ export const apps = pgTable('apps', { build_timeout_updated_at: timestamp('build_timeout_updated_at', { withTimezone: true }).notNull().defaultNow(), 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), @@ -57,6 +59,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', { @@ -88,6 +91,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..e34acfda6b --- /dev/null +++ b/supabase/functions/_backend/utils/rollout.ts @@ -0,0 +1,329 @@ +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 + const reportsRolloutVersion = input.currentVersionName === input.rolloutVersionName + const hasCachedRejection = cached?.selected === false + + if (!input.rolloutEnabled) { + return { + selected: false, + shouldWriteCache: false, + payload: cached, + reason: 'disabled', + ttlSeconds, + } + } + + if (reportsRolloutVersion && !hasCachedRejection) { + 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 (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, + } + } + + if (cached?.selected) { + return { + selected: true, + shouldWriteCache: false, + payload: cached, + reason: 'cached_selected', + 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/stats.ts b/supabase/functions/_backend/utils/stats.ts index cd05cc6a18..007a6e978b 100644 --- a/supabase/functions/_backend/utils/stats.ts +++ b/supabase/functions/_backend/utils/stats.ts @@ -2,7 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import type { Context } from 'hono' import type { MiddlewareKeyVariables } from './hono.ts' import type { Database } from './supabase.types.ts' -import type { DeviceRes, DeviceWithoutCreatedAt, NativeVersionUsage, ReadDevicesParams, ReadDevicesResponse, ReadStatsParams, StatsActions, StatsMetadata, VersionUsage } from './types.ts' +import type { DeviceRes, DeviceWithoutCreatedAt, NativeVersionUsage, ReadDevicesParams, ReadDevicesResponse, ReadStatsParams, StatsActions, StatsMetadata, VersionUsage, VersionUsageChannel } from './types.ts' import { getRuntimeKey } from 'hono/adapter' import { countDevicesCF, countUpdatesFromLogsCF, countUpdatesFromLogsExternalCF, createIfNotExistStoreInfo, getAppsFromCF, getUpdateStatsCF, readBandwidthUsageCF, readDevicesCF, readDeviceUsageCF, readDeviceVersionCountsCF, readNativeVersionUsageCF, readStatsCF, readStatsVersionCF, trackBandwidthUsageCF, trackDevicesCF, trackDeviceUsageCF, trackLogsCF, trackLogsCFExternal, trackVersionUsageCF, updateStoreApp } from './cloudflare.ts' import { isDemoApp } from './demo.ts' @@ -59,12 +59,12 @@ export function createStatsBandwidth(c: Context, device_id: string, app_id: stri } export type VersionAction = 'get' | 'fail' | 'install' | 'uninstall' -export function createStatsVersion(c: Context, version_name: string, app_id: string, action: VersionAction) { +export function createStatsVersion(c: Context, version_name: string, app_id: string, action: VersionAction, channel?: VersionUsageChannel | string | null) { if (isInternalVersionName(version_name)) return Promise.resolve() if (!c.env.VERSION_USAGE) - return backgroundTask(c, trackVersionUsageSB(c, version_name, app_id, action)) - return trackVersionUsageCF(c, version_name, app_id, action) + return backgroundTask(c, trackVersionUsageSB(c, version_name, app_id, action, channel)) + return trackVersionUsageCF(c, version_name, app_id, action, channel) } export function normalizeStatsMetadata(metadata?: StatsMetadata): StatsMetadata | undefined { @@ -151,10 +151,10 @@ export function readStatsStorage(c: Context, app_id: string, start_date: string, return readStatsStorageSB(c, app_id, start_date, end_date) } -export function readStatsVersion(c: Context, app_id: string, start_date: string, end_date: string): Promise { +export function readStatsVersion(c: Context, app_id: string, start_date: string, end_date: string, channel?: VersionUsageChannel | string): Promise { if (!c.env.VERSION_USAGE) - return readStatsVersionSB(c, app_id, start_date, end_date) - return readStatsVersionCF(c, app_id, start_date, end_date) + return readStatsVersionSB(c, app_id, start_date, end_date, channel) + return readStatsVersionCF(c, app_id, start_date, end_date, channel) } export function readNativeVersionUsage(c: Context, app_id: string, start_date: string, end_date: string, supabase: SupabaseClient): Promise { diff --git a/supabase/functions/_backend/utils/supabase.ts b/supabase/functions/_backend/utils/supabase.ts index 7f453e2503..6ceceda279 100644 --- a/supabase/functions/_backend/utils/supabase.ts +++ b/supabase/functions/_backend/utils/supabase.ts @@ -2,7 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import type { Context } from 'hono' import type { AuthInfo, MiddlewareKeyVariables } from './hono.ts' import type { Database } from './supabase.types.ts' -import type { DeviceWithoutCreatedAt, NativeVersionUsage, Order, ReadDevicesParams, ReadStatsParams, StatsMetadata, VersionUsage } from './types.ts' +import type { DeviceWithoutCreatedAt, NativeVersionUsage, Order, ReadDevicesParams, ReadStatsParams, StatsMetadata, VersionUsage, VersionUsageChannel } from './types.ts' import { createClient } from '@supabase/supabase-js' import { buildNormalizedDeviceForWrite, hasComparableDeviceChanged, nullableString } from './deviceComparison.ts' import { simpleError } from './hono.ts' @@ -1020,8 +1020,10 @@ export function trackVersionUsageSB( versionName: string, appId: string, action: Database['public']['Enums']['version_action'], + channel?: VersionUsageChannel | string | null, ) { - // Type cast needed: version_usage table now has version_name but auto-generated types are stale + const channelName = typeof channel === 'string' ? channel : channel?.name + const channelId = typeof channel === 'object' && channel ? channel.id : null return supabaseAdmin(c) .from('version_usage') .insert([ @@ -1029,7 +1031,9 @@ export function trackVersionUsageSB( version_name: versionName, app_id: appId, action, - } as unknown as { version_id: number, app_id: string, action: typeof action }, + channel_name: channelName ?? null, + channel_id: channelId ?? null, + }, ]) } @@ -1156,9 +1160,18 @@ export async function readStatsStorageSB(c: Context, app_id: string, period_star return data ?? [] } -export async function readStatsVersionSB(c: Context, app_id: string, period_start: string, period_end: string): Promise { +export async function readStatsVersionSB(c: Context, app_id: string, period_start: string, period_end: string, channel?: VersionUsageChannel | string): Promise { + const channelId = typeof channel === 'object' && channel ? channel.id : null + const channelName = typeof channel === 'string' ? channel : channelId ? null : channel?.name + const args = { + p_app_id: app_id, + p_period_start: period_start, + p_period_end: period_end, + ...(channelName ? { p_channel_name: channelName } : {}), + ...(channelId ? { p_channel_id: channelId } : {}), + } const { data } = await supabaseAdmin(c) - .rpc('read_version_usage', { p_app_id: app_id, p_period_start: period_start, p_period_end: period_end }) + .rpc('read_version_usage', args) // Cast to VersionUsage[] - the SQL function returns version_name but auto-generated types are stale return (data ?? []) as unknown as VersionUsage[] } diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index 5087733156..51a430d1cd 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -268,6 +268,8 @@ export type Database = { ios_store_url: string | null 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 @@ -295,6 +297,8 @@ export type Database = { ios_store_url?: string | null 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 @@ -322,6 +326,8 @@ export type Database = { ios_store_url?: string | null 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 @@ -713,6 +719,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 @@ -735,6 +758,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 @@ -757,6 +797,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 @@ -769,6 +826,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"] @@ -2714,6 +2778,8 @@ export type Database = { Row: { action: Database["public"]["Enums"]["version_action"] app_id: string + channel_id: number | null + channel_name: string | null timestamp: string version_id: number | null version_name: string | null @@ -2721,6 +2787,8 @@ export type Database = { Insert: { action: Database["public"]["Enums"]["version_action"] app_id: string + channel_id?: number | null + channel_name?: string | null timestamp?: string version_id?: number | null version_name?: string | null @@ -2728,6 +2796,8 @@ export type Database = { Update: { action?: Database["public"]["Enums"]["version_action"] app_id?: string + channel_id?: number | null + channel_name?: string | null timestamp?: string version_id?: number | null version_name?: string | null @@ -4253,7 +4323,7 @@ export type Database = { }[] } read_version_usage: { - Args: { p_app_id: string; p_period_end: string; p_period_start: string } + Args: { p_app_id: string; p_channel_id?: number | null; p_channel_name?: string | null; p_period_end: string; p_period_start: string } Returns: { app_id: string date: string diff --git a/supabase/functions/_backend/utils/types.ts b/supabase/functions/_backend/utils/types.ts index 1b76ebccd2..e9b51f9956 100644 --- a/supabase/functions/_backend/utils/types.ts +++ b/supabase/functions/_backend/utils/types.ts @@ -60,6 +60,11 @@ export interface VersionUsage { uninstall: number } +export interface VersionUsageChannel { + id?: number | null + name?: string | null +} + export interface NativeVersionUsage { date: string platform: string diff --git a/supabase/functions/_backend/utils/update.ts b/supabase/functions/_backend/utils/update.ts index ae7bb67775..8e59a85c6a 100644 --- a/supabase/functions/_backend/utils/update.ts +++ b/supabase/functions/_backend/utils/update.ts @@ -154,6 +154,8 @@ 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 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 @@ -167,6 +169,8 @@ export async function updateWithPG( channelDeviceCount, bypassChannelOverrides, manifestBundleCount, + rolloutChannelCount, + rolloutPausedVersionCount: rolloutPausedVersionNames.length, fetchManifestEntries, }) if (body.version_build === 'unknown') { @@ -216,7 +220,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, rolloutPausedVersionNames, version_name, needsMetadata) const { channelOverride } = requestedInto let { channelData } = requestedInto cloudlog({ requestId: c.get('requestId'), message: `channelData exists ? ${channelData !== undefined}, channelOverride exists ? ${channelOverride !== undefined}` }) @@ -456,7 +460,7 @@ export async function updateWithPG( // cloudlog(c.get('requestId'), 'save stats', device_id) device.version_name = version.name await Promise.all([ - createStatsVersion(c, version.name, app_id, 'get'), + createStatsVersion(c, version.name, app_id, 'get', { id: channelData.channels.id, name: channelData.channels.name }), sendStatsAndDevice(c, device, [{ action: 'get', versionName: version.name }]), ]) cloudlog({ requestId: c.get('requestId'), message: 'New version available', app_id, version: version.name, signedURL, date: new Date().toISOString() }) 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/20260510183100_random_sticky_rollouts.sql b/supabase/migrations/20260510183100_random_sticky_rollouts.sql new file mode 100644 index 0000000000..cd4dca26eb --- /dev/null +++ b/supabase/migrations/20260510183100_random_sticky_rollouts.sql @@ -0,0 +1,400 @@ +ALTER TABLE "public"."apps" +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); + +ALTER TABLE "public"."version_usage" +ADD COLUMN "channel_id" bigint; + +CREATE INDEX IF NOT EXISTS "idx_version_usage_app_channel_time" +ON "public"."version_usage" ("app_id", "channel_id", "timestamp") +WHERE "channel_id" IS NOT NULL; + +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_rollout_targets" +ON "public"."channels" ("app_id", "rollout_version") +WHERE "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_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; +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 + IF ("auth"."uid"() IS NOT NULL OR "public"."get_apikey_header"() IS NOT NULL) + AND NOT "public"."rbac_check_permission_request"( + "public"."rbac_perm_channel_promote_bundle"(), + NEW."owner_org", + NEW."app_id", + NEW."id" + ) + THEN + RAISE EXCEPTION 'NO_RIGHTS'; + END IF; + + NEW."rollout_id" = gen_random_uuid(); + IF NEW."rollout_version" IS NULL THEN + NEW."rollout_paused_at" = NULL; + IF NEW."rollout_pause_reason" IS NOT DISTINCT FROM OLD."rollout_pause_reason" THEN + NEW."rollout_pause_reason" = NULL; + END IF; + IF NEW."auto_pause_last_triggered_at" IS NOT DISTINCT FROM OLD."auto_pause_last_triggered_at" THEN + NEW."auto_pause_last_triggered_at" = NULL; + END IF; + ELSE + NEW."rollout_paused_at" = NULL; + NEW."rollout_pause_reason" = NULL; + NEW."auto_pause_last_triggered_at" = NULL; + END IF; + 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", "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" = ( + 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 +LANGUAGE plpgsql +SET search_path TO '' +AS $$ +BEGIN + UPDATE public.app_versions AS av + 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.name NOT IN ('builtin', 'unknown') + 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.app_id = av.app_id + AND (c.version = av.id OR c.rollout_version = av.id) + ); +END; +$$; + +ALTER FUNCTION public.update_app_versions_retention() OWNER TO postgres; +REVOKE ALL ON FUNCTION public.update_app_versions_retention() FROM PUBLIC; +GRANT ALL ON FUNCTION public.update_app_versions_retention() TO service_role; + +DROP FUNCTION IF EXISTS public.read_version_usage(character varying, timestamp without time zone, timestamp without time zone); +DROP FUNCTION IF EXISTS public.read_version_usage(character varying, timestamp without time zone, timestamp without time zone, text); + +CREATE OR REPLACE FUNCTION public.read_version_usage(p_app_id character varying, p_period_start timestamp without time zone, p_period_end timestamp without time zone, p_channel_name text DEFAULT NULL, p_channel_id bigint DEFAULT NULL) +RETURNS TABLE(app_id character varying, version_name character varying, date timestamp without time zone, "get" bigint, fail bigint, install bigint, uninstall bigint) +LANGUAGE plpgsql +SET search_path TO '' +AS $$ +BEGIN + RETURN QUERY + SELECT + vu.app_id, + COALESCE(vu.version_name, av.name)::character varying AS version_name, + DATE_TRUNC('day', vu.timestamp) AS date, + SUM(CASE WHEN vu.action = 'get' THEN 1 ELSE 0 END) AS "get", + SUM(CASE WHEN vu.action = 'fail' THEN 1 ELSE 0 END) AS fail, + SUM(CASE WHEN vu.action = 'install' THEN 1 ELSE 0 END) AS install, + SUM(CASE WHEN vu.action = 'uninstall' THEN 1 ELSE 0 END) AS uninstall + FROM public.version_usage AS vu + LEFT JOIN public.app_versions AS av ON vu.version_id = av.id AND vu.version_name IS NULL + WHERE vu.app_id = p_app_id + AND vu.timestamp >= p_period_start + AND vu.timestamp < p_period_end + AND (p_channel_name IS NULL OR vu.channel_name = p_channel_name) + AND (p_channel_id IS NULL OR vu.channel_id = p_channel_id) + GROUP BY date, vu.app_id, COALESCE(vu.version_name, av.name) + ORDER BY date; +END; +$$; + +ALTER FUNCTION public.read_version_usage(character varying, timestamp without time zone, timestamp without time zone, text, bigint) OWNER TO postgres; +REVOKE ALL ON FUNCTION public.read_version_usage(character varying, timestamp without time zone, timestamp without time zone, text, bigint) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.read_version_usage(character varying, timestamp without time zone, timestamp without time zone, text, bigint) TO anon; +GRANT EXECUTE ON FUNCTION public.read_version_usage(character varying, timestamp without time zone, timestamp without time zone, text, bigint) TO authenticated; +GRANT EXECUTE ON FUNCTION public.read_version_usage(character varying, timestamp without time zone, timestamp without time zone, text, bigint) TO service_role; + +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 AS av + 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.app_id = av.app_id + AND (c.version = av.id OR c.rollout_version = av.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/apikeys-expiration.test.ts b/tests/apikeys-expiration.test.ts index f144310f59..f671ea27d0 100644 --- a/tests/apikeys-expiration.test.ts +++ b/tests/apikeys-expiration.test.ts @@ -328,6 +328,7 @@ describe('[GET] /apikey with expiration info', () => { body: JSON.stringify({ name: keyName('key-with-exp-get-test'), mode: 'all', + limited_to_orgs: [BASE_ORG_ID], expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), }), }) diff --git a/tests/audit-logs.test.ts b/tests/audit-logs.test.ts index 0f65f544ae..c73792fce6 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-post.unit.test.ts b/tests/channel-post.unit.test.ts index 7769dee2eb..48fef172c6 100644 --- a/tests/channel-post.unit.test.ts +++ b/tests/channel-post.unit.test.ts @@ -31,7 +31,7 @@ vi.mock('../supabase/functions/_backend/utils/utils.ts', () => ({ isValidAppId, })) -function buildSupabaseChain(body: { ownerOrg?: string, versionId?: number }) { +function buildSupabaseChain(body: { existingChannelId?: number | null, existingChannelVersion?: number | null, existingRolloutVersion?: number | null, ownerOrg?: string, versionId?: number, eqCalls?: Array<[string, unknown]> }) { return { from(table: string) { if (table === 'apps') { @@ -44,10 +44,32 @@ function buildSupabaseChain(body: { ownerOrg?: string, versionId?: number }) { } } + if (table === 'channels') { + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + maybeSingle: async () => ({ + data: body.existingChannelId == null && body.existingChannelVersion == null && body.existingRolloutVersion == null + ? null + : { + id: body.existingChannelId ?? 99, + version: body.existingChannelVersion ?? null, + rollout_version: body.existingRolloutVersion ?? null, + }, + error: null, + }), + }), + }), + }), + } + } + if (table === 'app_versions') { return { select: () => ({ - eq() { + eq(column: string, value: unknown) { + body.eqCalls?.push([column, value]) return this }, single: async () => ({ data: { id: body.versionId ?? 123 }, error: null }), @@ -148,4 +170,67 @@ describe('public channel post', () => { }), ) }) + + it('filters numeric rollout target ids to active bundles', async () => { + const eqCalls: Array<[string, unknown]> = [] + supabaseApikey.mockImplementation(() => buildSupabaseChain({ existingChannelId: 42, existingChannelVersion: 123, versionId: 456, eqCalls })) + const { post } = await import('../supabase/functions/_backend/public/channel/post.ts') + + await post( + { json: vi.fn() } as any, + { + app_id: 'com.test.rollout-id', + channel: 'production', + version: '1.0.0', + rolloutVersion: 456, + }, + { user_id: 'user-test', key: 'capg-key' } as any, + ) + + const rolloutIdCallIndex = eqCalls.findIndex(([column, value]) => column === 'id' && value === 456) + expect(rolloutIdCallIndex).toBeGreaterThanOrEqual(0) + expect(eqCalls.slice(rolloutIdCallIndex)).toContainEqual(['deleted', false]) + expect(checkPermission).toHaveBeenCalledWith(expect.anything(), 'channel.promote_bundle', { appId: 'com.test.rollout-id', channelId: 42 }) + }) + + it('rejects rollout target changes without channel promote permission', async () => { + supabaseApikey.mockImplementation(() => buildSupabaseChain({ existingChannelId: 42, existingChannelVersion: 123 })) + checkPermission + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + const { post } = await import('../supabase/functions/_backend/public/channel/post.ts') + + await expect(post( + { json: vi.fn() } as any, + { + app_id: 'com.test.rollout-auth', + channel: 'production', + rolloutVersion: '2.0.0', + }, + { user_id: 'user-test', key: 'capg-key' } as any, + )).rejects.toMatchObject({ + cause: expect.objectContaining({ error: 'cannot_promote_bundle' }), + }) + + expect(updateOrCreateChannel).not.toHaveBeenCalled() + }) + + it('rejects rollout targets when a channel has no stable bundle', async () => { + supabaseApikey.mockImplementation(() => buildSupabaseChain({ existingChannelId: 42, existingChannelVersion: null })) + const { post } = await import('../supabase/functions/_backend/public/channel/post.ts') + + await expect(post( + { json: vi.fn() } as any, + { + app_id: 'com.test.rollout-no-stable', + channel: 'new-rollout-channel', + rolloutVersion: '2.0.0', + }, + { user_id: 'user-test', key: 'capg-key' } as any, + )).rejects.toMatchObject({ + cause: expect.objectContaining({ error: 'missing_stable_version' }), + }) + + expect(updateOrCreateChannel).not.toHaveBeenCalled() + }) }) 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..2ab52dfad7 100644 --- a/tests/expose-metadata.test.ts +++ b/tests/expose-metadata.test.ts @@ -18,6 +18,8 @@ import { const id = randomUUID() const APP_NAME_METADATA = `${APP_NAME}.${id}` +const USE_CLOUDFLARE = process.env.USE_CLOUDFLARE_WORKERS === 'true' +const describeBackend = describe.skipIf(USE_CLOUDFLARE) interface UpdateRes { error?: string @@ -30,15 +32,19 @@ interface UpdateRes { } beforeAll(async () => { + if (USE_CLOUDFLARE) + return await resetAndSeedAppData(APP_NAME_METADATA) }) afterAll(async () => { + if (USE_CLOUDFLARE) + return await resetAppData(APP_NAME_METADATA) await resetAppDataStats(APP_NAME_METADATA) }) -describe('expose_metadata feature', () => { +describeBackend('expose_metadata feature', () => { const supabase = getSupabaseClient() describe('[PUT] /app - expose_metadata field', () => { @@ -155,7 +161,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/get-identity-apikey-only-rpc.test.ts b/tests/get-identity-apikey-only-rpc.test.ts index 1e7ac183af..7299257958 100644 --- a/tests/get-identity-apikey-only-rpc.test.ts +++ b/tests/get-identity-apikey-only-rpc.test.ts @@ -22,6 +22,7 @@ function normalizeLocalhostUrl(raw: string | undefined): string { const SUPABASE_URL = normalizeLocalhostUrl(env.SUPABASE_URL) const SUPABASE_ANON_KEY = env.SUPABASE_ANON_KEY as string const SUPABASE_SERVICE_KEY = (env.SUPABASE_SERVICE_KEY || env.SUPABASE_SERVICE_ROLE_KEY || env.SERVICE_ROLE_KEY) as string +const USE_CLOUDFLARE = env.USE_CLOUDFLARE_WORKERS === 'true' const keyModes: Database['public']['Enums']['key_mode'][] = ['all', 'read', 'write'] @@ -59,7 +60,7 @@ async function createAuthenticatedClient() { }) } -describe('get_identity_apikey_only RPC permissions', () => { +describe.skipIf(USE_CLOUDFLARE)('get_identity_apikey_only RPC permissions', () => { it.concurrent('denies anonymous RPC access', async () => { const supabaseAnon = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { global: { headers: { capgkey: APIKEY_TEST_ALL } }, diff --git a/tests/hashed-apikey-rls.test.ts b/tests/hashed-apikey-rls.test.ts index 27c18bfd37..24efba1fb6 100644 --- a/tests/hashed-apikey-rls.test.ts +++ b/tests/hashed-apikey-rls.test.ts @@ -1042,16 +1042,21 @@ describe('rls policies with hashed api keys (via supabase sdk)', () => { }) describe('channels rls blocks direct api-key updates', () => { - let allKey: { id: number, key: string, key_hash: string } | null = null + let allKey: { id: number, key: string, key_hash: string, rbac_id?: string } | null = null let writeKey: { id: number, key: string, key_hash: string } | null = null let versionId: number | null = null let channelId: number | null = null + let appRbacId: string | null = null const versionName = `rls-direct-version-${randomUUID().slice(0, 8)}` const channelName = `rls-direct-channel-${randomUUID().slice(0, 8)}` beforeAll(async () => { allKey = await createHashedApiKey('test-channel-direct-all-key', 'all', [ORG_ID_RLS], [APP_NAME_RLS]) writeKey = await createHashedApiKey('test-channel-direct-write-key', 'write', [ORG_ID_RLS], [APP_NAME_RLS]) + const apiKeyResult = await pool.query('SELECT rbac_id FROM public.apikeys WHERE id = $1', [allKey.id]) + allKey.rbac_id = apiKeyResult.rows[0].rbac_id + const appResult = await pool.query('SELECT id FROM public.apps WHERE app_id = $1', [APP_NAME_RLS]) + appRbacId = appResult.rows[0].id const versionResult = await pool.query( `INSERT INTO public.app_versions (app_id, name, owner_org, user_id, checksum, storage_provider, r2_path, deleted) @@ -1152,6 +1157,99 @@ describe('channels rls blocks direct api-key updates', () => { [channelId], ) }) + + it('requires channel promote permission for direct rollout target changes', async () => { + if (!allKey || !allKey.rbac_id || !appRbacId || !channelId || !versionId) + throw new Error('RLS channel test setup did not complete') + + const orgResult = await pool.query('SELECT use_new_rbac FROM public.orgs WHERE id = $1', [ORG_ID_RLS]) + const previousUseNewRbac = orgResult.rows[0]?.use_new_rbac ?? false + await pool.query('UPDATE public.orgs SET use_new_rbac = true WHERE id = $1', [ORG_ID_RLS]) + await pool.query( + `INSERT INTO public.role_bindings ( + principal_type, principal_id, role_id, scope_type, org_id, app_id, granted_by, reason, is_direct + ) VALUES ( + public.rbac_principal_apikey(), + $1, + (SELECT id FROM public.roles WHERE name = public.rbac_role_app_admin()), + public.rbac_scope_app(), + $2, + $3, + $4, + 'hashed-apikey-rls channel direct update test', + true + )`, + [allKey.rbac_id, ORG_ID_RLS, appRbacId, USER_ID_RLS], + ) + await pool.query( + `INSERT INTO public.channel_permission_overrides ( + principal_type, principal_id, channel_id, permission_key, is_allowed + ) VALUES ( + public.rbac_principal_apikey(), + $1, + $2, + public.rbac_perm_channel_promote_bundle(), + false + ) + ON CONFLICT (principal_type, principal_id, channel_id, permission_key) + DO UPDATE SET is_allowed = excluded.is_allowed`, + [allKey.rbac_id, channelId], + ) + + try { + await expect(execWithRoleClaims( + 'UPDATE public.channels SET rollout_version = $1 WHERE id = $2 RETURNING id, rollout_version', + { + role: 'anon', + claims: { + role: 'anon', + aud: 'anon', + }, + headers: { capgkey: allKey.key }, + params: [versionId, channelId], + }, + )).rejects.toThrow(/NO_RIGHTS/) + + const result = await execWithRoleClaims( + 'UPDATE public.channels SET allow_emulator = true WHERE id = $1 RETURNING id, allow_emulator', + { + role: 'anon', + claims: { + role: 'anon', + aud: 'anon', + }, + headers: { capgkey: allKey.key }, + params: [channelId], + }, + ) + + expect(result.rowCount).toBe(1) + expect(result.rows[0].allow_emulator).toBe(true) + } + finally { + await pool.query( + `DELETE FROM public.channel_permission_overrides + WHERE principal_type = public.rbac_principal_apikey() + AND principal_id = $1 + AND channel_id = $2 + AND permission_key = public.rbac_perm_channel_promote_bundle()`, + [allKey.rbac_id, channelId], + ) + await pool.query( + `DELETE FROM public.role_bindings + WHERE principal_type = public.rbac_principal_apikey() + AND principal_id = $1 + AND app_id = $2 + AND scope_type = public.rbac_scope_app()`, + [allKey.rbac_id, appRbacId], + ) + await pool.query('UPDATE public.orgs SET use_new_rbac = $1 WHERE id = $2', [previousUseNewRbac, ORG_ID_RLS]) + await pool.query( + 'UPDATE public.channels SET allow_emulator = false, rollout_version = NULL WHERE id = $1', + [channelId], + ) + } + }) }) describe('webhook and webhook_delivery rls with api-key org scope precedence', () => { diff --git a/tests/password-policy.test.ts b/tests/password-policy.test.ts index 1246a3f90c..fe04812c0e 100644 --- a/tests/password-policy.test.ts +++ b/tests/password-policy.test.ts @@ -10,8 +10,13 @@ const ORG_ID = randomUUID() const globalId = randomUUID() const name = `Test Password Policy Org ${globalId}` const customerId = `cus_test_pwd_${ORG_ID}` +const USE_CLOUDFLARE = process.env.USE_CLOUDFLARE_WORKERS === 'true' +const describeSupabaseOnly = describe.skipIf(USE_CLOUDFLARE) beforeAll(async () => { + if (USE_CLOUDFLARE) + return + // Create stripe_info for this test org const { error: stripeError } = await getSupabaseClient().from('stripe_info').insert({ customer_id: customerId, @@ -46,6 +51,9 @@ beforeAll(async () => { }) afterAll(async () => { + if (USE_CLOUDFLARE) + return + // Clean up test organization and stripe_info await getSupabaseClient().from('user_password_compliance').delete().eq('org_id', ORG_ID) await getSupabaseClient().from('org_users').delete().eq('org_id', ORG_ID) @@ -53,7 +61,7 @@ afterAll(async () => { await getSupabaseClient().from('stripe_info').delete().eq('customer_id', customerId) }) -describe('password Policy Configuration via SDK', () => { +describeSupabaseOnly('password Policy Configuration via SDK', () => { it('enable password policy with all requirements via direct update', async () => { const policyConfig = { enabled: true, @@ -206,7 +214,7 @@ describe('password Policy Configuration via SDK', () => { }) }) -describe('[POST] /private/validate_password_compliance', () => { +describeSupabaseOnly('[POST] /private/validate_password_compliance', () => { beforeAll(async () => { // Enable password policy for testing await getSupabaseClient() @@ -588,7 +596,7 @@ describe('checkOrgReadAccess', () => { }) }) -describe('[GET] /private/check_org_members_password_policy', () => { +describeSupabaseOnly('[GET] /private/check_org_members_password_policy', () => { beforeAll(async () => { // Enable password policy for testing await getSupabaseClient() @@ -626,7 +634,7 @@ describe('[GET] /private/check_org_members_password_policy', () => { }) }) -describe('password Policy Enforcement Integration', () => { +describeSupabaseOnly('password Policy Enforcement Integration', () => { const orgWithPolicyId = randomUUID() const orgWithPolicyName = `Pwd Policy Integration Org ${randomUUID()}` const orgWithPolicyCustomerId = `cus_pwd_int_${orgWithPolicyId}` @@ -771,7 +779,7 @@ describe('password Policy Enforcement Integration', () => { }) }) -describe('user_password_compliance table', () => { +describeSupabaseOnly('user_password_compliance table', () => { it('can insert compliance record via service role', async () => { // Get the policy hash const { data: org, error: orgError } = await getSupabaseClient() diff --git a/tests/rollout.test.ts b/tests/rollout.test.ts new file mode 100644 index 0000000000..3fadb05db9 --- /dev/null +++ b/tests/rollout.test.ts @@ -0,0 +1,259 @@ +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.concurrent('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.concurrent('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.concurrent('does not honor cached selected devices when percentage is 0', () => { + const decision = resolveRolloutDecision({ + ...baseDecision, + rolloutPercentageBps: 0, + cachePayload: { + selected: true, + percentage_bps: 5000, + 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('percentage_zero') + expect(decision.shouldWriteCache).toBe(false) + }) + + it.concurrent('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.concurrent('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.concurrent('keeps devices already on rollout on rollout when paused and cache is missing', () => { + const decision = resolveRolloutDecision({ + ...baseDecision, + currentVersionName: '1.1.0', + rolloutEnabled: true, + 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.concurrent('does not honor paused rollout version reports when cache says unselected', () => { + const decision = resolveRolloutDecision({ + ...baseDecision, + currentVersionName: '1.1.0', + cachePayload: { + selected: false, + percentage_bps: 0, + 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, + }) + + expect(decision.selected).toBe(false) + expect(decision.reason).toBe('paused') + }) + + 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, + rolloutPausedAt: '2026-05-06T11:30:00.000Z', + rolloutPercentageBps: 10000, + randomBps: () => 0, + }) + + 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', () => { + it.concurrent('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.concurrent('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.concurrent('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/stats.test.ts b/tests/stats.test.ts index 983539faa4..9e5f803380 100644 --- a/tests/stats.test.ts +++ b/tests/stats.test.ts @@ -4,7 +4,7 @@ import { env } from 'node:process' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { ALLOWED_STATS_ACTIONS } from '../supabase/functions/_backend/plugins/stats_actions.ts' -import { APP_NAME, createAppVersions, getBaseData, getSupabaseClient, getVersionFromAction, headers, PLUGIN_BASE_URL, resetAndSeedAppData, resetAndSeedAppDataStats, resetAppData, resetAppDataStats } from './test-utils.ts' +import { APP_NAME, createAppVersions, getBaseData, getSupabaseClient, getVersionFromAction, headers, ORG_ID, PLUGIN_BASE_URL, resetAndSeedAppData, resetAndSeedAppDataStats, resetAppData, resetAppDataStats, USER_ID } from './test-utils.ts' const id = randomUUID() const APP_NAME_STATS = `${APP_NAME}.${id}` @@ -151,6 +151,129 @@ describe('[POST] /stats', () => { await getSupabaseClient().from('devices').delete().eq('device_id', uuid).eq('app_id', APP_NAME_STATS) }) + it('attributes version usage stats to the channel device override', async () => { + const shortId = randomUUID().split('-')[0] + const appId = `${APP_NAME}.stats.override.${shortId}` + const deviceId = randomUUID().toLowerCase() + const overrideChannelName = `beta-${shortId}` + await resetAndSeedAppData(appId) + await resetAndSeedAppDataStats(appId) + const supabase = getSupabaseClient() + + try { + const baseData = getBaseData(appId) as StatsPayload + baseData.device_id = deviceId + baseData.action = 'set' + baseData.defaultChannel = 'production' + baseData.channel = 'production' + baseData.version_build = getVersionFromAction('set') + const version = await createAppVersions(baseData.version_build, appId) + baseData.version_name = version.name + + const { data: channel, error: channelError } = await supabase + .from('channels') + .insert({ + app_id: appId, + name: overrideChannelName, + version: version.id, + created_by: USER_ID, + owner_org: ORG_ID, + }) + .select('id') + .single() + expect(channelError).toBeNull() + expect(channel).toBeTruthy() + + await supabase + .from('channel_devices') + .insert({ + app_id: appId, + channel_id: channel!.id, + device_id: deviceId, + owner_org: ORG_ID, + }) + .throwOnError() + + await supabase + .from('apps') + .update({ channel_device_count: 1 }) + .eq('app_id', appId) + .throwOnError() + + const response = await postStats(baseData) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ status: 'ok' }) + + const { data: usage, error: usageError } = await supabase + .from('version_usage') + .select('channel_id, channel_name') + .eq('app_id', appId) + .eq('version_name', version.name) + .eq('action', 'install') + .single() + + expect(usageError).toBeNull() + expect(usage?.channel_id).toBe(channel!.id) + expect(usage?.channel_name).toBe(overrideChannelName) + } + finally { + await resetAppData(appId) + await resetAppDataStats(appId) + } + }) + + it('does not trust client-supplied channel for version usage stats', async () => { + const shortId = randomUUID().split('-')[0] + const appId = `${APP_NAME}.stats.spoof.${shortId}` + const deviceId = randomUUID().toLowerCase() + const spoofedChannelName = `private-${shortId}` + await resetAndSeedAppData(appId) + await resetAndSeedAppDataStats(appId) + const supabase = getSupabaseClient() + + try { + const baseData = getBaseData(appId) as StatsPayload + baseData.device_id = deviceId + baseData.action = 'set' + delete baseData.defaultChannel + baseData.channel = spoofedChannelName + baseData.version_build = getVersionFromAction('set') + const version = await createAppVersions(baseData.version_build, appId) + baseData.version_name = version.name + + const { error: channelError } = await supabase + .from('channels') + .insert({ + app_id: appId, + name: spoofedChannelName, + version: version.id, + created_by: USER_ID, + owner_org: ORG_ID, + }) + expect(channelError).toBeNull() + + const response = await postStats(baseData) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ status: 'ok' }) + + const { data: usage, error: usageError } = await supabase + .from('version_usage') + .select('channel_name') + .eq('app_id', appId) + .eq('version_name', version.name) + .eq('action', 'install') + .single() + + expect(usageError).toBeNull() + expect(usage?.channel_name).toBe('production') + expect(usage?.channel_name).not.toBe(spoofedChannelName) + } + finally { + await resetAppData(appId) + await resetAppDataStats(appId) + } + }) + it('stores metadata for app and WebView health stats', async () => { const uuid = randomUUID().toLowerCase() const baseData = getBaseData(APP_NAME_STATS) as StatsPayload @@ -551,6 +674,114 @@ describe('[POST] /stats', () => { }) }) +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}` + await resetAndSeedAppData(appId) + await resetAndSeedAppDataStats(appId) + const supabase = getSupabaseClient() + + try { + const stableVersion = await createAppVersions(`1.0.0-stable-${shortId}.1`, appId) + const rolloutVersion = await createAppVersions(`1.0.0-rollout-${shortId}.1`, appId) + + const { data: channel, error: channelError } = await supabase + .from('channels') + .insert({ + app_id: appId, + name: `production-${shortId}`, + version: stableVersion.id, + rollout_version: rolloutVersion.id, + rollout_enabled: true, + rollout_percentage_bps: 5000, + created_by: USER_ID, + owner_org: ORG_ID, + }) + .select('id') + .single() + expect(channelError).toBeNull() + expect(channel).toBeTruthy() + + const triggeredAt = new Date().toISOString() + const reason = `Auto-pause rollback test ${shortId}` + await supabase + .from('channels') + .update({ + rollout_version: null, + rollout_enabled: false, + rollout_percentage_bps: 0, + rollout_paused_at: null, + rollout_pause_reason: reason, + auto_pause_last_triggered_at: triggeredAt, + }) + .eq('id', channel!.id) + .throwOnError() + + const { data: updatedChannel, error: updatedError } = await supabase + .from('channels') + .select('rollout_version, rollout_paused_at, rollout_pause_reason, auto_pause_last_triggered_at') + .eq('id', channel!.id) + .single() + + expect(updatedError).toBeNull() + expect(updatedChannel?.rollout_version).toBeNull() + expect(updatedChannel?.rollout_paused_at).toBeNull() + expect(updatedChannel?.rollout_pause_reason).toBe(reason) + expect(new Date(updatedChannel!.auto_pause_last_triggered_at!).toISOString()).toBe(triggeredAt) + } + finally { + await resetAppData(appId) + await resetAppDataStats(appId) + } + }) +}) + interface BatchStatsRes { status: string results?: Array<{ diff --git a/tests/updates.test.ts b/tests/updates.test.ts index d41d20df8d..17fc56798e 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() @@ -266,6 +266,153 @@ describe('[POST] /updates', () => { expect(json.checksum).toBe(expectedFallbackJson.checksum) }) + 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, { + external_url: `https://example.com/disabled-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: false, + rollout_percentage_bps: 0, + rollout_paused_at: null, + rollout_pause_reason: null, + }) + .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).not.toContain(rolloutVersionName) + + const baseData = getBaseData(APP_NAME_UPDATE) + baseData.version_name = rolloutVersionName + baseData.version_build = rolloutVersionName + + const response = await postUpdateAfterChannelMutation(baseData) + expect(response.status).toBe(200) + + const json = await response.json() + expect(json.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 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 diff --git a/tests/version-name-stats.test.ts b/tests/version-name-stats.test.ts index 564ccc915b..c892c0c7c0 100644 --- a/tests/version-name-stats.test.ts +++ b/tests/version-name-stats.test.ts @@ -23,6 +23,7 @@ const triggerHeaders = { describe('version_name statistics tracking', () => { let versionId: number + let channelId: number const versionName = '2.5.0-test' beforeAll(async () => { @@ -85,7 +86,7 @@ describe('version_name statistics tracking', () => { versionId = version!.id // Create channel for the app - await supabase + const { data: channel } = await supabase .from('channels') .insert({ name: 'production', @@ -94,7 +95,11 @@ describe('version_name statistics tracking', () => { created_by: '6aa76066-55ef-4238-ade6-0b32334a4097', owner_org: ORG_ID_VERSION_NAME, }) + .select('id') + .single() .throwOnError() + + channelId = channel!.id }) afterAll(async () => { @@ -208,6 +213,108 @@ describe('version_name statistics tracking', () => { expect(result.app_id).toBe(appId) }) + it('should filter read_version_usage by channel_name', async () => { + const supabase = getSupabaseClient() + const now = new Date() + const startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000) + const endDate = new Date(now.getTime() + 24 * 60 * 60 * 1000) + + await supabase + .from('version_usage') + .insert([ + { + app_id: appId, + version_name: versionName, + action: 'install', + timestamp: now.toISOString(), + channel_name: 'production', + }, + { + app_id: appId, + version_name: versionName, + action: 'install', + timestamp: now.toISOString(), + channel_name: 'beta', + }, + { + app_id: appId, + version_name: versionName, + action: 'fail', + timestamp: now.toISOString(), + channel_name: 'beta', + }, + ]) + .throwOnError() + + const { data, error } = await supabase.rpc('read_version_usage', { + p_app_id: appId, + p_period_start: startDate.toISOString().replace('T', ' ').replace('Z', ''), + p_period_end: endDate.toISOString().replace('T', ' ').replace('Z', ''), + p_channel_name: 'production', + }) + + expect(error).toBeNull() + expect(data).toBeTruthy() + expect(data!.length).toBeGreaterThan(0) + + const installs = data!.reduce((total, row) => total + Number(row.install ?? 0), 0) + const failures = data!.reduce((total, row) => total + Number(row.fail ?? 0), 0) + expect(installs).toBe(1) + expect(failures).toBe(0) + }) + + it('should filter read_version_usage by channel_id after channel rename', async () => { + const supabase = getSupabaseClient() + const now = new Date() + const startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000) + const endDate = new Date(now.getTime() + 24 * 60 * 60 * 1000) + + await supabase + .from('version_usage') + .insert([ + { + app_id: appId, + version_name: versionName, + action: 'install', + timestamp: now.toISOString(), + channel_id: channelId, + channel_name: 'production', + }, + { + app_id: appId, + version_name: versionName, + action: 'fail', + timestamp: now.toISOString(), + channel_id: channelId, + channel_name: 'production-renamed', + }, + { + app_id: appId, + version_name: versionName, + action: 'fail', + timestamp: now.toISOString(), + channel_id: channelId + 9999, + channel_name: 'production', + }, + ]) + .throwOnError() + + const { data, error } = await supabase.rpc('read_version_usage', { + p_app_id: appId, + p_period_start: startDate.toISOString().replace('T', ' ').replace('Z', ''), + p_period_end: endDate.toISOString().replace('T', ' ').replace('Z', ''), + p_channel_id: channelId, + }) + + expect(error).toBeNull() + expect(data).toBeTruthy() + + const installs = data!.reduce((total, row) => total + Number(row.install ?? 0), 0) + const failures = data!.reduce((total, row) => total + Number(row.fail ?? 0), 0) + expect(installs).toBe(1) + expect(failures).toBe(1) + }) + it('should handle daily_version upsert with version_name correctly', async () => { const supabase = getSupabaseClient() diff --git a/tests/webhooks.test.ts b/tests/webhooks.test.ts index 8b09b49bff..5134ca177c 100644 --- a/tests/webhooks.test.ts +++ b/tests/webhooks.test.ts @@ -10,6 +10,8 @@ const webhookAppId = `com.webhooks.${globalId}` const webhookName = `Test Webhook ${globalId}` const webhookUrl = 'https://example.com/webhook' const customerId = `cus_test_${WEBHOOK_TEST_ORG_ID}` +const USE_CLOUDFLARE = process.env.USE_CLOUDFLARE_WORKERS === 'true' +const describeBackend = describe.skipIf(USE_CLOUDFLARE) let createdWebhookId: string | null = null let lastDeliveryId: string | null = null @@ -18,6 +20,8 @@ let appScopedKey: string | null = null let orgScopedSubkeyId: number | null = null beforeAll(async () => { + if (USE_CLOUDFLARE) + return // Create stripe_info for this test org const { error: stripeError } = await getSupabaseClient().from('stripe_info').insert({ customer_id: customerId, @@ -83,6 +87,8 @@ beforeAll(async () => { }) afterAll(async () => { + if (USE_CLOUDFLARE) + return // Clean up created webhooks // Note: Using type assertion as webhooks table types are not yet generated if (createdWebhookId) { @@ -100,7 +106,7 @@ afterAll(async () => { await getSupabaseClient().from('stripe_info').delete().eq('customer_id', customerId) }, 60000) -describe('[GET] /webhooks', () => { +describeBackend('[GET] /webhooks', () => { it('list webhooks for organization', async () => { const response = await fetchWithRetry(`${BASE_URL}/webhooks?orgId=${WEBHOOK_TEST_ORG_ID}`, { headers, @@ -146,7 +152,7 @@ describe('[GET] /webhooks', () => { }) }) -describe('[POST] /webhooks', () => { +describeBackend('[POST] /webhooks', () => { it('create webhook', async () => { const response = await fetch(`${BASE_URL}/webhooks`, { method: 'POST', @@ -280,7 +286,7 @@ describe('[POST] /webhooks', () => { }) }) -describe('[GET] /webhooks (single webhook)', () => { +describeBackend('[GET] /webhooks (single webhook)', () => { it('get single webhook by id', async () => { if (!createdWebhookId) throw new Error('Webhook was not created in previous test') @@ -306,7 +312,7 @@ describe('[GET] /webhooks (single webhook)', () => { }) }) -describe('[PUT] /webhooks', () => { +describeBackend('[PUT] /webhooks', () => { it('update webhook name', async () => { if (!createdWebhookId) throw new Error('Webhook was not created in previous test') @@ -464,7 +470,7 @@ describe('[PUT] /webhooks', () => { }) }) -describe('[POST] /webhooks/test', () => { +describeBackend('[POST] /webhooks/test', () => { it('test webhook', async () => { if (!createdWebhookId) throw new Error('Webhook was not created in previous test') @@ -559,7 +565,7 @@ describe('[POST] /webhooks/test', () => { }) }) -describe('[GET] /webhooks/deliveries', () => { +describeBackend('[GET] /webhooks/deliveries', () => { it('get webhook deliveries', async () => { if (!createdWebhookId) throw new Error('Webhook was not created in previous test') @@ -619,7 +625,7 @@ describe('[GET] /webhooks/deliveries', () => { }) }) -describe('[POST] /webhooks/deliveries/retry', () => { +describeBackend('[POST] /webhooks/deliveries/retry', () => { it('retry delivery with invalid deliveryId', async () => { const invalidDeliveryId = randomUUID() const response = await fetch(`${BASE_URL}/webhooks/deliveries/retry`, { @@ -693,7 +699,7 @@ describe('[POST] /webhooks/deliveries/retry', () => { }) }) -describe('[DELETE] /webhooks', () => { +describeBackend('[DELETE] /webhooks', () => { it('delete webhook with invalid webhookId', async () => { const invalidWebhookId = randomUUID() const response = await fetch(`${BASE_URL}/webhooks?orgId=${WEBHOOK_TEST_ORG_ID}&webhookId=${invalidWebhookId}`, {