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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions cli/src/bundle/compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,45 @@ export async function checkCompatibilityInternal(
}
}

// Surface incompatible results from the explicit `capgo bundle compatibility`
// command to Bento via the backend. The silent internal callers (sdk.ts,
// releaseType.ts) are intentionally excluded. The command uploads nothing, so
// only the channel's current (old) version is reported — version_new is empty.
if (hasIncompatible && !silent) {
// Best-effort telemetry: the org/version lookups are awaited, so a network
// or auth failure must be swallowed here — it must never break the command.
try {
const [channelResult, appResult] = await Promise.all([
supabase.from('channels').select('version ( id, name )').eq('name', channel).eq('app_id', resolvedAppId).maybeSingle(),
supabase.from('apps').select('owner_org').eq('app_id', resolvedAppId).maybeSingle(),
])
const oldVersion = (channelResult.data?.version ?? undefined) as unknown as { id?: number | string, name?: string } | undefined
// The command re-queries the channel separately, so version_old can come
// back unresolved (race with a channel repoint, or a transient failure).
// version_old is required for this signal — skip the event entirely rather
// than emit one missing the version it is about.
if (oldVersion?.id != null) {
void trackEvent({
channel: 'bundle',
event: 'Bundle Incompatible',
icon: '🚫',
apikey: enrichedOptions.apikey,
appId: resolvedAppId,
orgId: appResult.data?.owner_org ?? undefined,
tags: {
source: 'command',
channel,
version_old_id: String(oldVersion.id),
...(oldVersion.name ? { version_old_name: oldVersion.name } : {}),
},
})
}
}
catch {
// telemetry must never break a command
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return {
finalCompatibility: compatibility.finalCompatibility,
hasIncompatible,
Expand Down
41 changes: 38 additions & 3 deletions cli/src/bundle/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,14 +140,19 @@ 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 ( id, name, min_update_version, native_packages )')
.eq('name', channel)
.eq('app_id', appid)
.maybeSingle()

if (channelError)
uploadFail(`Cannot load channel ${channel} for compatibility checks ${formatError(channelError)}`)

// The version currently live on the channel — what the new bundle is compared
// against. Captured here (before the channel is repointed at the new bundle)
// so the incompatible-bundle Bento signal can report the prior version.
const oldVersion = (channelData?.version ?? undefined) as unknown as { id?: number | string, name?: string } | undefined

const updateMetadataRequired = !!channelData && channelData.disable_auto_update === 'version_number'

let localDependencies: Awaited<ReturnType<typeof getLocalDependencies>> | undefined
Expand Down Expand Up @@ -251,7 +256,15 @@ async function verifyCompatibility(supabase: SupabaseType, pm: pmType, options:
}))
: undefined

return { nativePackages, minUpdateVersion }
return {
nativePackages,
minUpdateVersion,
compatibility: {
result: compatibilitySummary.result,
versionOldId: oldVersion?.id != null ? String(oldVersion.id) : undefined,
versionOldName: oldVersion?.name,
},
}
}

async function checkVersionExists(supabase: SupabaseType, appid: string, bundle: string, versionExistsOk = false, interactive = false): Promise<boolean | string> {
Expand Down Expand Up @@ -882,7 +895,7 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl
if (options.verbose)
log.info(`[Verbose] Checking compatibility with channel ${channel}...`)

const { nativePackages, minUpdateVersion } = await verifyCompatibility(supabase, pm, options, channel, appid, bundle, orgId)
const { nativePackages, minUpdateVersion, compatibility } = await verifyCompatibility(supabase, pm, options, channel, appid, bundle, orgId)
if (options.verbose) {
log.info(`[Verbose] Compatibility check completed:`)
log.info(` - Native packages: ${nativePackages ? nativePackages.length : 0}`)
Expand Down Expand Up @@ -1310,6 +1323,28 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl
notifyConsole: true,
}).catch(() => {})

// Surface incompatible uploads to Bento via the backend (it resolves
// version_new_id + org/app names and gates delivery via the
// bundle_incompatible preference). Fires only once the version exists and the
// channel has been set, and only when the bundle was actually incompatible.
if (compatibility?.result === 'incompatible') {
void trackEvent({
channel: 'bundle',
event: 'Bundle Incompatible',
icon: '🚫',
apikey,
appId: appid,
orgId,
tags: {
source: 'upload',
channel,
version_new_name: bundle,
...(compatibility.versionOldId ? { version_old_id: compatibility.versionOldId } : {}),
...(compatibility.versionOldName ? { version_old_name: compatibility.versionOldName } : {}),
},
})
}

const result: UploadBundleResult = {
success: true,
bundle,
Expand Down
2 changes: 2 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1340,6 +1340,8 @@
"notifications-bundle-created-desc": "Receive emails when a new bundle is uploaded to your app",
"notifications-bundle-deployed": "Bundle deployments",
"notifications-bundle-deployed-desc": "Receive emails when a bundle is deployed to a production channel",
"notifications-bundle-incompatible": "Incompatible bundles",
"notifications-bundle-incompatible-desc": "Receive emails when an uploaded bundle is incompatible with the channel's native packages and may need a native app store update",
"notifications-channel-self-rejected": "Channel self-assignment rejected",
"notifications-channel-self-rejected-desc": "Receive emails when devices are rejected from self-assigning to channels",
"notifications-cli-realtime-feed": "CLI activity notifications",
Expand Down
7 changes: 7 additions & 0 deletions src/pages/settings/account/Notifications.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface EmailPreferences {
bundle_deployed?: boolean
device_error?: boolean
channel_self_rejected?: boolean
bundle_incompatible?: boolean
cli_realtime_feed?: boolean
}

Expand Down Expand Up @@ -221,6 +222,12 @@ async function toggleEmailPref(key: EmailPreferenceKey) {
@change="toggleEmailPref('channel_self_rejected')"
/>
</InfoRow>
<InfoRow :label="t('notifications-bundle-incompatible')" :editable="false" :value="t('notifications-bundle-incompatible-desc')">
<Toggle
:value="getEmailPref('bundle_incompatible')"
@change="toggleEmailPref('bundle_incompatible')"
/>
</InfoRow>
</dl>

<!-- Realtime CLI Feed Section -->
Expand Down
63 changes: 62 additions & 1 deletion supabase/functions/_backend/private/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { MiddlewareKeyVariables } from '../utils/hono.ts'
import type { BentoTrackingPayload } from '../utils/tracking.ts'
import { Hono } from 'hono/tiny'
import { BUILDER_RECOVERY_MILESTONES, buildBuilderOnboardingBentoEvent } from '../utils/builder_onboarding_recovery.ts'
import { BUNDLE_INCOMPATIBLE_EVENT, buildBundleCompatibilityBentoEvent } from '../utils/bundle_compatibility_recovery.ts'
import { BRES, parseBody, quickError, simpleError, useCors } from '../utils/hono.ts'
import { middlewareV2 } from '../utils/hono_middleware.ts'
import { cloudlog } from '../utils/logging.ts'
Expand Down Expand Up @@ -36,6 +37,16 @@ function isTrackingV2(version: unknown) {
return version === 2 || version === '2'
}

// Coerce a tag/DB value (string id, numeric/bigint id, or missing) into a
// non-empty string id or undefined — keeps the Bento payload *_id fields clean.
function toIdString(value: unknown): string | undefined {
if (typeof value === 'string')
return value.length > 0 ? value : undefined
if (typeof value === 'number' || typeof value === 'bigint')
return String(value)
return undefined
}

async function resolveTrackingUserId(
c: Context<MiddlewareKeyVariables>,
requestedUserId: string | undefined,
Expand Down Expand Up @@ -253,8 +264,58 @@ app.post('/', middlewareV2(['read', 'write', 'all', 'upload']), async (c) => {
}
}

// Bundle compatibility failure (capgo bundle upload / bundle compatibility):
// when the CLI reports an incompatible bundle, emit a Bento signal so a
// lifecycle automation can react. Mirrors the builder block above; resolves
// org/app names + the freshly created version id for the payload.
let bundleIncompatibleBentoEvent: BentoTrackingPayload | undefined
if (onboardingOrgId && appId && trackedBody.event === BUNDLE_INCOMPATIBLE_EVENT) {
const tags = trackedBody.tags ?? {}
const versionNewName = typeof tags.version_new_name === 'string' && tags.version_new_name.length > 0
? tags.version_new_name
: undefined
const [orgResult, appResult] = await Promise.all([
supabase.from('orgs').select('id, name').eq('id', onboardingOrgId).single(),
supabase.from('apps').select('name').eq('app_id', appId).single(),
])
if (orgResult.error || appResult.error) {
// Best-effort signal: never fail the CLI's request, and don't emit a Bento
// event with empty org/app context. Log and skip instead.
cloudlog({ requestId: c.get('requestId'), message: 'bundle incompatible bento lookup failed; skipping signal', org: orgResult.error, app: appResult.error })
}
else {
// The upload flow sends only version_new_name; resolve its id here (the
// version exists by the time this event is sent). The command flow uploads
// nothing, so versionNewName is absent and version_new_id stays empty.
let versionNewId: string | undefined
if (versionNewName) {
const { data: versionNewData } = await supabase
.from('app_versions')
.select('id')
.eq('app_id', appId)
.eq('name', versionNewName)
.maybeSingle()
versionNewId = toIdString(versionNewData?.id)
}
const versionOldId = toIdString(tags.version_old_id)
bundleIncompatibleBentoEvent = buildBundleCompatibilityBentoEvent({
event: trackedBody.event,
orgId: onboardingOrgId,
appId,
channel: typeof tags.channel === 'string' ? tags.channel : undefined,
source: typeof tags.source === 'string' ? tags.source : undefined,
versionNewId,
versionNewName,
versionOldId,
versionOldName: typeof tags.version_old_name === 'string' ? tags.version_old_name : undefined,
orgName: orgResult.data?.name ?? undefined,
appName: appResult.data?.name ?? undefined,
})
}
}

// Exactly one of these is ever set (distinct event names); `??` picks the active one.
const bentoEvent = onboardingBentoEvent ?? builderBentoEvent
const bentoEvent = onboardingBentoEvent ?? builderBentoEvent ?? bundleIncompatibleBentoEvent
await sendEventToTracking(c, {
...trackedBody,
bento: bentoEvent,
Expand Down
79 changes: 79 additions & 0 deletions supabase/functions/_backend/utils/bundle_compatibility_recovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { BentoTrackingPayload } from './tracking.ts'

/**
* The CLI emits a `Bundle Incompatible` tracking event whenever a bundle's
* native dependencies don't match the version currently live on the target
* channel. It fires from two flows:
* - `bundle upload` (after the new version is created), and
* - the standalone `capgo bundle compatibility` command.
*
* We turn that into a Bento signal event so a lifecycle automation can react
* (e.g. nudge the org toward a native rebuild / Capgo Builder). Delivery and
* email gating are handled by `sendNotifToOrgMembers` via the dedicated
* `bundle_incompatible` preference key. Mirrors `buildBuilderOnboardingBentoEvent`.
*/
export const BUNDLE_INCOMPATIBLE_EVENT = 'Bundle Incompatible'

export interface BundleCompatibilityBentoInput {
/** The incoming tracking event name (must be 'Bundle Incompatible'). */
event: string
orgId: string | undefined
appId: string | undefined
/** Channel the bundle was checked against. */
channel: string | undefined
/** Which flow detected the incompatibility: 'upload' | 'command'. */
source: string | undefined
/**
* New bundle being uploaded. Empty for the standalone command, which uploads
* nothing — only the upload flow has a freshly created version.
*/
versionNewId: string | undefined
versionNewName: string | undefined
/** Version currently live on the channel that the bundle was compared against. */
versionOldId: string | undefined
versionOldName: string | undefined
orgName: string | undefined
appName: string | undefined
}

/**
* Pure: decide whether this event should emit a Bento signal and build its
* payload. Returns undefined when nothing should be emitted (wrong event name,
* or missing org/app context).
*/
export function buildBundleCompatibilityBentoEvent(input: BundleCompatibilityBentoInput): BentoTrackingPayload | undefined {
if (input.event !== BUNDLE_INCOMPATIBLE_EVENT)
return undefined
if (!input.orgId || !input.appId)
return undefined

const source = input.source ?? 'unknown'
const channel = input.channel ?? ''
const versionNewName = input.versionNewName ?? ''
const versionOldName = input.versionOldName ?? ''

return {
event: 'bundle_incompatible',
// Dedicated key — independent from other bundle/OTA email preferences.
preferenceKey: 'bundle_incompatible',
// Permanent per app+channel+version claim (no reopening cron window): repeated
// incompatible uploads / `bundle compatibility` checks of the SAME version must
// not re-email org admins. A genuinely new version has a different uniqId and
// notifies on its own; the old version is the fallback for the command flow
// (which uploads no new bundle).
once: true,
uniqId: `bundle_incompatible:${input.appId}:${channel}:${versionNewName || versionOldName}`,
data: {
org_id: input.orgId,
org_name: input.orgName ?? '',
app_id: input.appId,
app_name: input.appName ?? '',
channel,
source,
version_new_id: input.versionNewId ?? '',
version_new_name: versionNewName,
version_old_id: input.versionOldId ?? '',
version_old_name: versionOldName,
},
}
}
2 changes: 2 additions & 0 deletions supabase/functions/_backend/utils/org_email_notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export type EmailPreferenceKey
| 'channel_self_rejected'
| 'daily_fail_ratio'
| 'cli_realtime_feed'
| 'bundle_incompatible'

export interface EmailPreferences {
usage_limit?: boolean
Expand All @@ -93,6 +94,7 @@ export interface EmailPreferences {
channel_self_rejected?: boolean
daily_fail_ratio?: boolean
cli_realtime_feed?: boolean
bundle_incompatible?: boolean
}

interface OrgWithPreferences {
Expand Down
46 changes: 34 additions & 12 deletions supabase/functions/_backend/utils/tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,22 @@ import type { Context } from 'hono'
import type { PostHogGroups } from './posthog.ts'
import { cloudlogErr, serializeError } from './logging.ts'
import { logsnag } from './logsnag.ts'
import { sendNotifToOrgMembers } from './org_email_notifications.ts'
import { sendNotifToOrgMembers, sendNotifToOrgMembersOnce } from './org_email_notifications.ts'
import { getDrizzleClient, getPgClient } from './pg.ts'
import { trackPosthogEvent } from './posthog.ts'
import { backgroundTask } from './utils.ts'

export interface BentoTrackingPayload {
cron: string
/** Cron window for the throttle/dedupe. Used only when `once` is not set. */
cron?: string
data: Record<string, unknown>
event: string
/**
* Send at most ONE notification ever per (event, org, uniqId) via a permanent
* claim instead of a reopening cron window. Use for per-entity alerts that must
* not re-fire on retries (e.g. an incompatible bundle version). Ignores `cron`.
*/
once?: boolean
preferenceKey: import('./org_email_notifications.ts').EmailPreferenceKey
uniqId: string
}
Expand Down Expand Up @@ -97,16 +104,31 @@ async function executeBentoTracking(c: Context, payload: SendEventToTrackingPayl
await runTrackedCall(c, 'bento', async () => {
const pgClient = getPgClient(c, true)
try {
await sendNotifToOrgMembers(
c,
bento.event,
bento.preferenceKey,
bento.data,
orgId,
bento.uniqId,
bento.cron,
getDrizzleClient(pgClient),
)
if (bento.once) {
// Permanent per-(event, org, uniqId) claim: per-entity alerts (e.g. an
// incompatible bundle version) must not re-email org admins on retries.
await sendNotifToOrgMembersOnce(
c,
bento.event,
bento.preferenceKey,
bento.data,
orgId,
bento.uniqId,
getDrizzleClient(pgClient),
)
}
else {
await sendNotifToOrgMembers(
c,
bento.event,
bento.preferenceKey,
bento.data,
orgId,
bento.uniqId,
bento.cron ?? '* * * * *',
getDrizzleClient(pgClient),
)
}
}
finally {
await pgClient.end()
Expand Down
Loading
Loading