From 5c9a090b83f42547491282fe579de4f643ecdb58 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Thu, 11 Jun 2026 08:25:31 +0200 Subject: [PATCH 1/3] fix(backend): don't raise a mirror compatibility event on guarded rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rolling a channel back to the baseline of an unresolved compatibility event re-enters on_channel_update, which raised a fresh mirror event (prev/cur swapped) while only the original auto-resolved — so the recommended remediation (roll back) created an endless chain of unresolved events. decideCompatibilityEvents now receives the channel's unresolved events and suppresses an event whose new bundle is a known unresolved baseline for the same channel+platform (a revert). Suppression only applies while disable_auto_update_under_native is ON: the update endpoint then refuses 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 the mirror event is a real warning and is still raised. Channel-scoped, unresolved-only (an accepted incompatibility later reverted still raises). 4 new unit tests (suppress / guard-off / wrong-channel / unknown-baseline). --- .../_backend/triggers/compatibility_events.ts | 35 +++++- .../_backend/triggers/on_channel_update.ts | 23 +++- .../compatibility-events-decide.unit.test.ts | 102 ++++++++++++++++++ 3 files changed, 157 insertions(+), 3 deletions(-) diff --git a/supabase/functions/_backend/triggers/compatibility_events.ts b/supabase/functions/_backend/triggers/compatibility_events.ts index 4fb0275b8f..1c49d12755 100644 --- a/supabase/functions/_backend/triggers/compatibility_events.ts +++ b/supabase/functions/_backend/triggers/compatibility_events.ts @@ -44,7 +44,7 @@ export interface PreviousDefault { export interface DecideCompatibilityEventsInput { /** The new/current default channel row (`c.get('webhookBody')`). */ - newChannel: Pick + newChannel: Pick /** 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. */ @@ -56,6 +56,15 @@ export interface DecideCompatibilityEventsInput { * 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[] } /** @@ -117,7 +126,7 @@ function hasNativePackages(bundle: CompatibilityBundle | null | undefined): bund * run it). The handler only excludes when the metadata is genuinely unavailable. */ export function decideCompatibilityEvents(input: DecideCompatibilityEventsInput): CompatibilityEventInsert[] { - 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) @@ -147,6 +156,27 @@ export function decideCompatibilityEvents(input: DecideCompatibilityEventsInput) 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, + ) + if (isRevertToKnownBaseline) + continue + } + const summary: CompatibilitySummary = summarizeBundleCompatibility( compareNativePackages(currentBundle.nativePackages, previous.bundle.nativePackages), ) @@ -177,6 +207,7 @@ export function decideCompatibilityEvents(input: DecideCompatibilityEventsInput) 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 diff --git a/supabase/functions/_backend/triggers/on_channel_update.ts b/supabase/functions/_backend/triggers/on_channel_update.ts index faa343718a..34562f6765 100644 --- a/supabase/functions/_backend/triggers/on_channel_update.ts +++ b/supabase/functions/_backend/triggers/on_channel_update.ts @@ -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) { @@ -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 }) diff --git a/tests/compatibility-events-decide.unit.test.ts b/tests/compatibility-events-decide.unit.test.ts index a6106b9530..4e02ae357c 100644 --- a/tests/compatibility-events-decide.unit.test.ts +++ b/tests/compatibility-events-decide.unit.test.ts @@ -37,6 +37,8 @@ function newChannel(overrides: Partial { 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', () => { @@ -218,6 +319,7 @@ describe('decideAutoResolves', () => { return { id: 1, platform: 'ios', + channel_id: 101, previous_version_id: 600, previous_version_name: '6.0.0', current_version_id: 700, From a9cdfce379a535fc5cfc8d04f8918fbe94e711de Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Thu, 11 Jun 2026 08:37:14 +0200 Subject: [PATCH 2/3] fix(backend): suppress only the direct inverse rollback transition Address review: also require the unresolved event's current_version_id to match the change's previous bundle, so only the exact mirror (700 -> 600 while 600 -> 700 is open) is suppressed; a non-inverse transition into a known baseline (800 -> 600) still raises its own event since its immediate baseline is 800. New unit test for that case. --- .../_backend/triggers/compatibility_events.ts | 18 ++++++++---- .../compatibility-events-decide.unit.test.ts | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/supabase/functions/_backend/triggers/compatibility_events.ts b/supabase/functions/_backend/triggers/compatibility_events.ts index 1c49d12755..8ea86f35f2 100644 --- a/supabase/functions/_backend/triggers/compatibility_events.ts +++ b/supabase/functions/_backend/triggers/compatibility_events.ts @@ -156,11 +156,14 @@ export function decideCompatibilityEvents(input: DecideCompatibilityEventsInput) 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 + // REVERT: this change is the exact inverse of an unresolved event for this + // channel+platform (current/previous swapped) — i.e., the channel returns to + // the baseline the event says users are on, which 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. + // Requiring BOTH ids to match keeps any non-inverse transition (e.g. an + // 800 -> 600 change while a 600 -> 700 event is open) raising its own event. // // Only safe to suppress while the channel's downgrade guard is on: // `disable_auto_update_under_native` makes the update endpoint refuse to @@ -168,12 +171,15 @@ export function decideCompatibilityEvents(input: DecideCompatibilityEventsInput) // 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 => + // hasNativePackages' narrowing does not carry into the closure below. + const previousBundleId = previous.bundle.id + const isDirectRollback = unresolvedEvents.some(event => event.channel_id === newChannel.id && event.platform === previous.platform - && event.previous_version_id === currentBundle.id, + && event.previous_version_id === currentBundle.id + && event.current_version_id === previousBundleId, ) - if (isRevertToKnownBaseline) + if (isDirectRollback) continue } diff --git a/tests/compatibility-events-decide.unit.test.ts b/tests/compatibility-events-decide.unit.test.ts index 4e02ae357c..7b6f623069 100644 --- a/tests/compatibility-events-decide.unit.test.ts +++ b/tests/compatibility-events-decide.unit.test.ts @@ -240,6 +240,34 @@ describe('decideCompatibilityEvents', () => { expect(events).toHaveLength(0) }) + it('does NOT suppress a non-inverse transition into a known baseline (800 -> 600 with 600 -> 700 open)', () => { + // Only the exact inverse of the open event (700 -> 600) is a rollback; a + // transition from some other bundle (800) into the baseline must still + // raise its own event, because its immediate baseline is 800, not 700. + const PKG_V8: NativePackage[] = [{ name: '@capacitor/core', version: '8.0.0' }] + 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(800, '8.0.0', PKG_V8), + }], + 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: 800, current_version_id: 600 }) + }) + 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 From 14abb04aebf1066c1189f5051a41d2ebf250a8a1 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Thu, 11 Jun 2026 08:48:52 +0200 Subject: [PATCH 3/3] test: use it.concurrent + camelCase local in new compatibility tests Address review: the repo convention is it.concurrent for concurrency-safe unit tests, and SCREAMING_SNAKE_CASE is reserved for module-level constants. Applied to the 5 tests this PR adds (pre-existing tests left untouched to keep the diff scoped). --- tests/compatibility-events-decide.unit.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/compatibility-events-decide.unit.test.ts b/tests/compatibility-events-decide.unit.test.ts index 7b6f623069..26aba57bc5 100644 --- a/tests/compatibility-events-decide.unit.test.ts +++ b/tests/compatibility-events-decide.unit.test.ts @@ -214,7 +214,7 @@ describe('decideCompatibilityEvents', () => { expect(events).toHaveLength(0) }) - it('suppresses the mirror event when reverting to an unresolved event\'s baseline', () => { + it.concurrent('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. @@ -240,11 +240,11 @@ describe('decideCompatibilityEvents', () => { expect(events).toHaveLength(0) }) - it('does NOT suppress a non-inverse transition into a known baseline (800 -> 600 with 600 -> 700 open)', () => { + it.concurrent('does NOT suppress a non-inverse transition into a known baseline (800 -> 600 with 600 -> 700 open)', () => { // Only the exact inverse of the open event (700 -> 600) is a rollback; a // transition from some other bundle (800) into the baseline must still // raise its own event, because its immediate baseline is 800, not 700. - const PKG_V8: NativePackage[] = [{ name: '@capacitor/core', version: '8.0.0' }] + const pkgV8: NativePackage[] = [{ name: '@capacitor/core', version: '8.0.0' }] const events = decideCompatibilityEvents({ changeOccurredAt: CHANGE_AT, newChannel: newChannel({ version: 600 }), @@ -252,7 +252,7 @@ describe('decideCompatibilityEvents', () => { previousDefaults: [{ platform: 'ios', source: 'default_channel_version_changed', - bundle: bundle(800, '8.0.0', PKG_V8), + bundle: bundle(800, '8.0.0', pkgV8), }], unresolvedEvents: [{ id: 1, @@ -268,7 +268,7 @@ describe('decideCompatibilityEvents', () => { expect(events[0]).toMatchObject({ previous_version_id: 800, current_version_id: 600 }) }) - it('does NOT suppress the mirror event when the downgrade guard is off', () => { + it.concurrent('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. @@ -295,7 +295,7 @@ describe('decideCompatibilityEvents', () => { expect(events[0]).toMatchObject({ previous_version_id: 700, current_version_id: 600 }) }) - it('does NOT suppress when the unresolved baseline belongs to another channel', () => { + it.concurrent('does NOT suppress when the unresolved baseline belongs to another channel', () => { const events = decideCompatibilityEvents({ changeOccurredAt: CHANGE_AT, newChannel: newChannel({ version: 600 }), @@ -318,7 +318,7 @@ describe('decideCompatibilityEvents', () => { expect(events).toHaveLength(1) }) - it('does NOT suppress an incompatible change to a bundle no unresolved event knows as a baseline', () => { + it.concurrent('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 }),