Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
35 changes: 33 additions & 2 deletions supabase/functions/_backend/triggers/compatibility_events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@

export interface DecideCompatibilityEventsInput {
/** The new/current default channel row (`c.get('webhookBody')`). */
newChannel: Pick<ChannelRow, 'id' | 'app_id' | 'owner_org' | 'name' | 'version' | 'public' | 'ios' | 'android' | 'electron' | 'disable_auto_update'>
newChannel: Pick<ChannelRow, 'id' | 'app_id' | 'owner_org' | 'name' | 'version' | 'public' | 'ios' | 'android' | 'electron' | 'disable_auto_update' | 'disable_auto_update_under_native'>
/** The bundle the new default channel now points at (`app_versions` of `newChannel.version`). */
currentBundle: CompatibilityBundle | null
/** Per-platform previous-default candidates the handler resolved. */
Expand All @@ -56,6 +56,15 @@
* transition carries a new one and inserts a fresh, unresolved row.
*/
changeOccurredAt: string
/**
* Unresolved events already on file for this channel, used to recognize a
* REVERT: when the new default bundle is the `previous_version` of an
* unresolved event, users are being returned to the baseline they are
* already on, so no new (mirror) event should be raised — otherwise the
* recommended remediation (roll the channel back) would itself raise a fresh
* unresolved event, forever.
*/
unresolvedEvents?: readonly UnresolvedCompatibilityEvent[]
}

/**
Expand Down Expand Up @@ -116,8 +125,8 @@
* `deleted` but still carries `native_packages` is a valid baseline (users still
* run it). The handler only excludes when the metadata is genuinely unavailable.
*/
export function decideCompatibilityEvents(input: DecideCompatibilityEventsInput): CompatibilityEventInsert[] {

Check failure on line 128 in supabase/functions/_backend/triggers/compatibility_events.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZ61YXrW3C0qR_tYsF86&open=AZ61YXrW3C0qR_tYsF86&pullRequest=2482
const { newChannel, currentBundle, previousDefaults, changeOccurredAt } = input
const { newChannel, currentBundle, previousDefaults, changeOccurredAt, unresolvedEvents = [] } = input

// The new channel must be a default (public) for any platform to matter.
if (!newChannel.public)
Expand Down Expand Up @@ -147,6 +156,27 @@
if (previous.bundle.id === currentBundle.id)
continue

// REVERT: the new default is a baseline an unresolved event already says
// this channel's users are on. Returning them to it is the remediation the
// event recommends, not a new incompatibility — raising a mirror event here
// would loop forever (each rollback raising the next event). The matching
// unresolved event is auto-resolved by decideAutoResolves on this same pass.
//
// Only safe to suppress while the channel's downgrade guard is on:
// `disable_auto_update_under_native` makes the update endpoint refuse to
// serve a bundle below a device's native version, so devices that already
// installed the newer native build cannot receive the rolled-back bundle.
// With the guard off that delivery is possible and the event must be raised.
if (newChannel.disable_auto_update_under_native) {
const isRevertToKnownBaseline = unresolvedEvents.some(event =>
event.channel_id === newChannel.id
&& event.platform === previous.platform
&& event.previous_version_id === currentBundle.id,
Comment thread
WcaleNieWolny marked this conversation as resolved.
Outdated
)
if (isRevertToKnownBaseline)
continue
}

const summary: CompatibilitySummary = summarizeBundleCompatibility(
compareNativePackages(currentBundle.nativePackages, previous.bundle.nativePackages),
)
Expand Down Expand Up @@ -177,6 +207,7 @@
export interface UnresolvedCompatibilityEvent {
id: number
platform: CompatibilityPlatform
channel_id: number | null
previous_version_id: number | null
previous_version_name: string
current_version_id: number | null
Expand Down
23 changes: 22 additions & 1 deletion supabase/functions/_backend/triggers/on_channel_update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,11 +272,32 @@ async function persistCompatibilityEvents(
}
}

// Unresolved events for this channel, so the decision layer can recognize a
// revert to a known baseline and skip raising a mirror event for it.
const { data: channelUnresolved, error: channelUnresolvedError } = await supabaseAdmin(c)
.from('compatibility_events')
.select('id, platform, channel_id, previous_version_id, previous_version_name, current_version_id')
.eq('app_id', record.app_id)
.eq('channel_id', record.id)
.is('resolved_at', null)
.order('id', { ascending: false })
.limit(COMPATIBILITY_AUTO_RESOLVE_SCAN_LIMIT)
if (channelUnresolvedError) {
cloudlogErr({
requestId: c.get('requestId'),
message: 'Failed to load unresolved compatibility events for revert detection',
error: channelUnresolvedError,
app_id: record.app_id,
channel_id: record.id,
})
}

const events = decideCompatibilityEvents({
newChannel: record,
currentBundle,
previousDefaults,
changeOccurredAt: record.updated_at ?? new Date().toISOString(),
unresolvedEvents: (channelUnresolved ?? []) as UnresolvedCompatibilityEvent[],
})

for (const event of events) {
Expand Down Expand Up @@ -326,7 +347,7 @@ async function autoResolveCompatibilityEvents(

const { data: unresolved, error } = await supabaseAdmin(c)
.from('compatibility_events')
.select('id, platform, previous_version_id, previous_version_name, current_version_id')
.select('id, platform, channel_id, previous_version_id, previous_version_name, current_version_id')
.eq('app_id', record.app_id)
.is('resolved_at', null)
.order('id', { ascending: false })
Expand Down
102 changes: 102 additions & 0 deletions tests/compatibility-events-decide.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ function newChannel(overrides: Partial<DecideCompatibilityEventsInput['newChanne
android: false,
electron: false,
disable_auto_update: 'major',
// The platform downgrade guard, on by default (mirrors the column default).
disable_auto_update_under_native: true,
...overrides,
}
}
Expand Down Expand Up @@ -211,13 +213,113 @@ describe('decideCompatibilityEvents', () => {

expect(events).toHaveLength(0)
})

it('suppresses the mirror event when reverting to an unresolved event\'s baseline', () => {
// E1 (prev=600, cur=700) is unresolved; the channel reverts 700 -> 600.
// Without suppression this raises E2 (prev=700, cur=600) and every rollback
// would raise the next mirror event, forever.
const events = decideCompatibilityEvents({
changeOccurredAt: CHANGE_AT,
newChannel: newChannel({ version: 600 }),
currentBundle: bundle(600, '6.0.0', PKG_V6),
previousDefaults: [{
platform: 'ios',
source: 'default_channel_version_changed',
bundle: bundle(700, '7.0.0', PKG_V7),
}],
unresolvedEvents: [{
id: 1,
platform: 'ios',
channel_id: 101,
previous_version_id: 600,
previous_version_name: '6.0.0',
current_version_id: 700,
}],
})

expect(events).toHaveLength(0)
})

it('does NOT suppress the mirror event when the downgrade guard is off', () => {
// With disable_auto_update_under_native off, devices that already installed
// the newer native build CAN receive the rolled-back bundle, so the mirror
// event is a real warning and must be raised.
const events = decideCompatibilityEvents({
changeOccurredAt: CHANGE_AT,
newChannel: newChannel({ version: 600, disable_auto_update_under_native: false }),
currentBundle: bundle(600, '6.0.0', PKG_V6),
previousDefaults: [{
platform: 'ios',
source: 'default_channel_version_changed',
bundle: bundle(700, '7.0.0', PKG_V7),
}],
unresolvedEvents: [{
id: 1,
platform: 'ios',
channel_id: 101,
previous_version_id: 600,
previous_version_name: '6.0.0',
current_version_id: 700,
}],
})

expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({ previous_version_id: 700, current_version_id: 600 })
})

it('does NOT suppress when the unresolved baseline belongs to another channel', () => {
const events = decideCompatibilityEvents({
changeOccurredAt: CHANGE_AT,
newChannel: newChannel({ version: 600 }),
currentBundle: bundle(600, '6.0.0', PKG_V6),
previousDefaults: [{
platform: 'ios',
source: 'default_channel_version_changed',
bundle: bundle(700, '7.0.0', PKG_V7),
}],
unresolvedEvents: [{
id: 1,
platform: 'ios',
channel_id: 999,
previous_version_id: 600,
previous_version_name: '6.0.0',
current_version_id: 700,
}],
})

expect(events).toHaveLength(1)
})

it('does NOT suppress an incompatible change to a bundle no unresolved event knows as a baseline', () => {
const events = decideCompatibilityEvents({
changeOccurredAt: CHANGE_AT,
newChannel: newChannel({ version: 600 }),
currentBundle: bundle(600, '6.0.0', PKG_V6),
previousDefaults: [{
platform: 'ios',
source: 'default_channel_version_changed',
bundle: bundle(700, '7.0.0', PKG_V7),
}],
unresolvedEvents: [{
id: 1,
platform: 'ios',
channel_id: 101,
previous_version_id: 500,
previous_version_name: '5.0.0',
current_version_id: 700,
}],
})

expect(events).toHaveLength(1)
})
})

describe('decideAutoResolves', () => {
function unresolved(overrides: Partial<UnresolvedCompatibilityEvent> = {}): UnresolvedCompatibilityEvent {
return {
id: 1,
platform: 'ios',
channel_id: 101,
previous_version_id: 600,
previous_version_name: '6.0.0',
current_version_id: 700,
Expand Down
Loading