From a2af00878eb308306ef5f28e6b83ff298dff97da Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 19 May 2026 22:26:12 +0200 Subject: [PATCH 1/3] fix(db): remove app version placeholders --- cli/src/api/channels.ts | 69 +----- cli/src/channel/add.ts | 11 +- cli/src/types/supabase.types.ts | 8 +- cli/src/utils.ts | 2 +- messages/en.json | 1 + src/components/tables/BundleTable.vue | 25 +- src/components/tables/ChannelTable.vue | 28 +-- src/pages/app/[app].bundle.[bundle].vue | 47 +--- .../app/[app].channel.[channel].devices.vue | 11 +- src/pages/app/[app].channel.[channel].vue | 54 +---- src/pages/app/[app].device.[device].vue | 18 -- src/services/supabase.ts | 2 +- src/services/versions.ts | 45 ++++ src/types/supabase.types.ts | 8 +- supabase/functions/_backend/files/preview.ts | 5 +- supabase/functions/_backend/plugins/stats.ts | 14 +- .../functions/_backend/public/app/demo.ts | 14 +- .../functions/_backend/public/channel/post.ts | 10 +- .../_backend/triggers/cron_clear_versions.ts | 13 +- .../_backend/triggers/on_app_create.ts | 22 -- supabase/functions/_backend/utils/pg.ts | 44 ++-- .../_backend/utils/postgres_schema.ts | 2 +- .../_backend/utils/supabase.types.ts | 8 +- ...50_remove_builtin_unknown_app_versions.sql | 223 ++++++++++++++++++ supabase/seed.sql | 18 +- .../18_test_utility_functions_extended.sql | 4 +- supabase/tests/33_test_rbac_phase1.sql | 2 +- tests/bundle.test.ts | 12 +- tests/channel-post.unit.test.ts | 1 + tests/cli-channel.test.ts | 2 +- tests/stats.test.ts | 15 +- tests/updates.test.ts | 12 +- 32 files changed, 374 insertions(+), 376 deletions(-) create mode 100644 supabase/migrations/20260519151250_remove_builtin_unknown_app_versions.sql diff --git a/cli/src/api/channels.ts b/cli/src/api/channels.ts index 653b024482..f75844946d 100644 --- a/cli/src/api/channels.ts +++ b/cli/src/api/channels.ts @@ -2,7 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import type { Database } from '../types/supabase.types' import { confirm as confirmC, intro, log, outro, spinner } from '@clack/prompts' import { Table } from '@sauber/table' -import { formatError, getOrganizationId } from '../utils' +import { formatError } from '../utils' interface CheckVersionOptions { silent?: boolean @@ -66,14 +66,9 @@ export async function checkVersionNotUsedInChannel( const s = silent ? null : spinner() s?.start(`Unlinking channel ${channel.name}`) - const unknownVersion = await findUnknownVersion(supabase, appid, { silent }) - if (!unknownVersion) { - s?.stop(`Cannot find unknown version for ${appid}`) - throw new Error(`Cannot find unknown version for ${appid}`) - } const { error: errorChannelUpdate } = await supabase .from('channels') - .update({ version: unknownVersion.id }) + .update({ version: null }) .eq('id', channel.id) if (errorChannelUpdate) { @@ -88,66 +83,6 @@ export async function checkVersionNotUsedInChannel( outro(`Version unlinked from ${channelFound.length} channel${channelFound.length > 1 ? 's' : ''}`) } -interface FindUnknownOptions { - silent?: boolean -} - -export async function findUnknownVersion( - supabase: SupabaseClient, - appId: string, - options: FindUnknownOptions = {}, -) { - const { silent = false } = options - - // Try to find existing unknown version - const { data, error: findError } = await supabase - .from('app_versions') - .select('id') - .eq('app_id', appId) - .eq('name', 'unknown') - .maybeSingle() - - if (findError) { - if (!silent) - log.error(`Cannot find unknown version for ${appId}: ${formatError(findError)}`) - throw new Error(`Cannot find unknown version for app ${appId}: ${formatError(findError)}`) - } - - if (data) { - return data - } - - // Not found - create or reuse the synthetic placeholder version safely. - try { - const orgId = await getOrganizationId(supabase, appId) - const { data: newVersion, error: createError } = await supabase - .from('app_versions') - .upsert({ - owner_org: orgId, - deleted: true, - name: 'unknown', - app_id: appId, - }) - .select('id') - .eq('app_id', appId) - .eq('name', 'unknown') - .single() - - if (createError) { - if (!silent) - log.error(`Cannot create unknown version for ${appId}: ${formatError(createError)}`) - throw new Error(`Cannot find or create unknown version for app ${appId}: ${formatError(createError)}`) - } - - return newVersion - } - catch (createErr) { - if (!silent) - log.error(`Cannot create unknown version for ${appId}: ${formatError(createErr)}`) - throw new Error(`Cannot retrieve or create unknown version for app ${appId}: ${formatError(createErr)}`) - } -} - export function createChannel( supabase: SupabaseClient, update: Database['public']['Tables']['channels']['Insert'], diff --git a/cli/src/channel/add.ts b/cli/src/channel/add.ts index c2d7d9b623..a7f1affc13 100644 --- a/cli/src/channel/add.ts +++ b/cli/src/channel/add.ts @@ -1,7 +1,7 @@ import type { ChannelAddOptions } from '../schemas/channel' import { intro, log, outro } from '@clack/prompts' import { check2FAComplianceForApp, checkAppExistsAndHasPermissionOrgErr } from '../api/app' -import { createChannel, findUnknownVersion } from '../api/channels' +import { createChannel } from '../api/channels' import { createSupabaseClient, findSavedKey, @@ -42,20 +42,13 @@ export async function addChannelInternal(channelId: string, appId: string, optio if (!silent) log.info(`Creating channel ${appId}#${channelId} to Capgo`) - const data = await findUnknownVersion(supabase, appId, { silent }) - if (!data) { - if (!silent) - log.error('Cannot find default version for channel creation, please contact Capgo support 🤨') - throw new Error('Cannot find default version for channel creation') - } - const orgId = await getOrganizationId(supabase, appId) const userId = await resolveUserIdFromApiKey(supabase, options.apikey) const res = await createChannel(supabase, { name: channelId, app_id: appId, - version: data.id, + version: null, created_by: userId, owner_org: orgId, allow_device_self_set: options.selfAssign ?? false, diff --git a/cli/src/types/supabase.types.ts b/cli/src/types/supabase.types.ts index 3c9fad6950..2dac28ab4d 100644 --- a/cli/src/types/supabase.types.ts +++ b/cli/src/types/supabase.types.ts @@ -715,7 +715,7 @@ export type Database = { public: boolean rbac_id: string updated_at: string - version: number + version: number | null } Insert: { allow_dev?: boolean @@ -737,7 +737,7 @@ export type Database = { public?: boolean rbac_id?: string updated_at?: string - version: number + version?: number | null } Update: { allow_dev?: boolean @@ -759,7 +759,7 @@ export type Database = { public?: boolean rbac_id?: string updated_at?: string - version?: number + version?: number | null } Relationships: [ { @@ -2993,7 +2993,7 @@ export type Database = { } check_revert_to_builtin_version: { Args: { appid: string } - Returns: number + Returns: number | null } cleanup_expired_apikeys: { Args: never; Returns: undefined } cleanup_expired_demo_apps: { Args: never; Returns: undefined } diff --git a/cli/src/utils.ts b/cli/src/utils.ts index 66c7003b71..90609ce692 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -1998,7 +1998,7 @@ export async function getRemoteDependencies(supabase: SupabaseClient, log.error(`Error fetching native packages: ${error.message}`) throw new Error(`Error fetching native packages: ${error.message}`) } - return convertNativePackages((remoteNativePackages.version.native_packages as any) ?? []) + return convertNativePackages(((remoteNativePackages.version as any)?.native_packages as any) ?? []) } export async function checkChecksum(supabase: SupabaseClient, appId: string, channel: string, currentChecksum: string) { diff --git a/messages/en.json b/messages/en.json index a619e9c0fe..3853c14539 100644 --- a/messages/en.json +++ b/messages/en.json @@ -596,6 +596,7 @@ "channel": "Channel", "channel-actions": "channel actions", "channel-allow-device-self-set": "Allow Device Self-Assignment", + "channel-builtin": "builtin", "channel-bundle-linked": "This bundle is linked to one or more channels. ({channels}) Would you like to unlink it from those channels?", "channel-create": "Create channel", "channel-created": "Channel Created", diff --git a/src/components/tables/BundleTable.vue b/src/components/tables/BundleTable.vue index 0bc8e7304b..7e7bbfcaa5 100644 --- a/src/components/tables/BundleTable.vue +++ b/src/components/tables/BundleTable.vue @@ -295,32 +295,13 @@ async function refreshData() { } } -async function unlinkChannels(app_id: string, unlink: LinkedChannel[]) { +async function unlinkChannels(_appId: string, unlink: LinkedChannel[]) { if (unlink.length === 0) { return } - const { data: unknownVersion, error: unknownError } = await supabase - .from('app_versions') - .select() - .eq('app_id', app_id) - .eq('name', 'unknown') - .single() - - if (unknownError) { - toast.error(t('cannot-find-unknown-version')) - console.error('Cannot find unknown', JSON.stringify(unknownError)) - return Promise.reject(new Error('Cannot find unknown')) - } - - if (!unknownVersion?.id || typeof unknownVersion.id !== 'number') { - toast.error(t('error-invalid-version')) - console.error('Invalid unknown version ID:', unknownVersion) - return Promise.reject(new Error('Invalid unknown version ID')) - } - const { error: updateError } = await supabase .from('channels') - .update({ version: unknownVersion.id }) + .update({ version: null }) .in('id', unlink.map(c => c.id)) if (updateError) { @@ -535,7 +516,7 @@ async function massDelete() { } const channelsList = linkedChannelsList - .map(val => val.rawChannel?.map((ch: any) => `${ch.name} (${ch.version.name})`).join(', ')) + .map(val => val.rawChannel?.map((ch: any) => `${ch.name} (${ch.version?.name ?? t('channel-builtin')})`).join(', ')) .join(', ') const message = t('channel-bundle-linked', { channels: channelsList }) const shouldUnlink = await showUnlinkDialog(message) diff --git a/src/components/tables/ChannelTable.vue b/src/components/tables/ChannelTable.vue index 46ed1c7239..693d49d54f 100644 --- a/src/components/tables/ChannelTable.vue +++ b/src/components/tables/ChannelTable.vue @@ -30,7 +30,7 @@ interface Channel { name: string created_at: string min_update_version: string | null - } + } | null misconfigured: boolean | undefined } type Element = Database['public']['Tables']['channels']['Row'] & Channel @@ -47,7 +47,7 @@ const search = ref('') const elements = ref<(Element)[]>([]) const isLoading = ref(true) const currentPage = ref(1) -const versionId = ref() +const versionId = ref(null) const filters = ref() const newChannelName = ref('') const canPromoteChannel = ref>({}) @@ -70,19 +70,8 @@ const currentVersionsNumber = computed(() => { }) const { currentOrganization } = storeToRefs(organizationStore) -function findUnknownVersion() { - return supabase - .from('app_versions') - .select('id') - .eq('app_id', props.appId) - .eq('name', 'unknown') - .throwOnError() - .single() - .then(({ data }) => data?.id) -} - async function addChannel(name: string) { - if (!name || !versionId.value || !main.user) + if (!name || !main.user) return try { console.log('addChannel', name, versionId.value, main.user) @@ -96,7 +85,7 @@ async function addChannel(name: string) { { name, app_id: props.appId, - version: versionId.value as number, + version: versionId.value, owner_org: currentGid as string, created_by: main.user?.id, }, @@ -164,7 +153,7 @@ async function getData() { .map(e => e as any as Element) for (const channel of channels) { - if (channel.version.min_update_version === null) { + if (channel.version && channel.version.min_update_version === null) { channel.misconfigured = true anyMisconfigured = true } @@ -172,7 +161,7 @@ async function getData() { // Inform the parent component if there are any misconfigured channels emit('misconfigured', anyMisconfigured) - versionId.value = await findUnknownVersion() + versionId.value = null await loadChannelPermissions(elements.value) } catch (error) { @@ -309,7 +298,7 @@ columns.value = [ key: 'version', mobile: true, sortable: true, - displayFunction: (elem: Element) => elem.version.name, + displayFunction: (elem: Element) => elem.version?.name ?? t('channel-builtin'), onClick: (elem: Element) => openOneVersion(elem), }, { @@ -382,7 +371,8 @@ async function showAddModal() { } async function openOneVersion(one: Element) { - router.push(`/app/${props.appId}/bundle/${one.version?.id}`) + if (one.version?.id) + router.push(`/app/${props.appId}/bundle/${one.version.id}`) } async function openOne(one: Element) { diff --git a/src/pages/app/[app].bundle.[bundle].vue b/src/pages/app/[app].bundle.[bundle].vue index 5596052855..b8dcd80374 100644 --- a/src/pages/app/[app].bundle.[bundle].vue +++ b/src/pages/app/[app].bundle.[bundle].vue @@ -220,26 +220,14 @@ const checksumInfo = computed(() => { return getChecksumInfo(version.value?.checksum) }) -async function getUnknownBundleId() { - if (!version.value) - return - const { data } = await supabase - .from('app_versions') - .select() - .eq('app_id', version.value.app_id) - .eq('name', 'unknown') - .single() - return data?.id -} - // add check compatibility here -async function setChannel(channel: Database['public']['Tables']['channels']['Row'], id: number) { +async function setChannel(channel: Database['public']['Tables']['channels']['Row'], id: number | null) { if (!canPromoteChannel(channel.id)) { toast.error(t('no-permission')) return Promise.reject(new Error('No permission')) } - if (!id || typeof id !== 'number') { + if (id !== null && typeof id !== 'number') { console.error('Invalid version ID:', id) toast.error(t('error-invalid-version')) return Promise.reject(new Error('Invalid version ID')) @@ -256,6 +244,7 @@ async function setChannel(channel: Database['public']['Tables']['channels']['Row version: id, }) .eq('id', channel.id) + .throwOnError() } async function ASChannelChooser() { @@ -398,10 +387,7 @@ async function handleChannelAction(action: 'set' | 'open' | 'unlink') { } else if (action === 'unlink') { try { - const id = await getUnknownBundleId() - if (!id) - return - await setChannel(channel.value, id) + await setChannel(channel.value, null) await getChannels() toast.success(t('channels-unlinked-successfully')) toast.info(t('cloud-replication-delay')) @@ -702,33 +688,14 @@ async function didCancel(name: string, askForMethod = true): Promise c.id)) if (updateError) { @@ -774,7 +741,7 @@ async function deleteBundle() { dialogStore.openDialog({ title: t('want-to-unlink'), description: t('channel-bundle-linked', { - channels: channelFound.map((ch: any) => `${ch.name} (${ch.version.name})`).join(', '), + channels: channelFound.map((ch: any) => `${ch.name} (${ch.version?.name ?? t('channel-builtin')})`).join(', '), }), buttons: [ { diff --git a/src/pages/app/[app].channel.[channel].devices.vue b/src/pages/app/[app].channel.[channel].devices.vue index 2d09c6c534..ab9fd50920 100644 --- a/src/pages/app/[app].channel.[channel].devices.vue +++ b/src/pages/app/[app].channel.[channel].devices.vue @@ -9,6 +9,7 @@ import { toast } from 'vue-sonner' import plusOutline from '~icons/ion/add-outline' import IconAlertCircle from '~icons/lucide/alert-circle' import { useSupabase } from '~/services/supabase' +import { withBuiltinChannelVersion } from '~/services/versions' import { useAppDetailStore } from '~/stores/appDetail' import { useDialogV2Store } from '~/stores/dialogv2' import { useDisplayStore } from '~/stores/display' @@ -96,7 +97,7 @@ async function customDeviceOverwritePart4( ) { dialogStore.openDialog({ title: t('confirm-overwrite'), - description: t('confirm-overwrite-msg').replace('$1', deviceId).replace('$2', channel.value?.name ?? '').replace('$3', channel.value?.version.name ?? ''), + description: t('confirm-overwrite-msg').replace('$1', deviceId).replace('$2', channel.value?.name ?? '').replace('$3', channel.value?.version?.name ?? t('channel-builtin')), buttons: [ { text: t('no'), @@ -130,7 +131,7 @@ async function customDeviceOverwritePart5( app_id: route.params.app as string, org_id: channel.value?.owner_org ?? '', platform, - version_name: channel.value?.version.name ?? 'unknown', + version_name: channel.value?.version?.name ?? 'builtin', }, }) @@ -166,7 +167,7 @@ async function getDeviceIds() { .from('channel_devices') .select('device_id') .eq('channel_id', id.value) - .eq('app_id', channel.value.version.app_id) + .eq('app_id', channel.value.app_id) if (dataDevices && dataDevices.length) deviceIds.value = dataDevices.map(d => d.device_id) else @@ -183,7 +184,7 @@ async function getChannel() { // Check if we already have this channel in the store if (appDetailStore.currentChannelId === id.value && appDetailStore.currentChannel) { - channel.value = appDetailStore.currentChannel as any + channel.value = withBuiltinChannelVersion(appDetailStore.currentChannel as any) as any if (channel.value?.name) displayStore.setChannelName(String(channel.value.id), channel.value.name) displayStore.NavTitle = channel.value?.name ?? t('channel') @@ -228,7 +229,7 @@ async function getChannel() { return } - channel.value = data as unknown as Database['public']['Tables']['channels']['Row'] & Channel + channel.value = withBuiltinChannelVersion(data as any) as unknown as Database['public']['Tables']['channels']['Row'] & Channel // Store in appDetailStore appDetailStore.setChannel(id.value, channel.value) diff --git a/src/pages/app/[app].channel.[channel].vue b/src/pages/app/[app].channel.[channel].vue index 21ab958312..4a280a221b 100644 --- a/src/pages/app/[app].channel.[channel].vue +++ b/src/pages/app/[app].channel.[channel].vue @@ -19,7 +19,7 @@ import IconDown from '~icons/material-symbols/keyboard-arrow-down-rounded' import { formatDate, formatLocalDate } from '~/services/date' import { checkPermissions } from '~/services/permissions' import { checkCompatibilityNativePackages, defaultApiHost, isCompatible, useSupabase } from '~/services/supabase' -import { isInternalVersionName } from '~/services/versions' +import { isInternalVersionName, withBuiltinChannelVersion } from '~/services/versions' import { useAppDetailStore } from '~/stores/appDetail' import { useDialogV2Store } from '~/stores/dialogv2' import { useDisplayStore } from '~/stores/display' @@ -79,7 +79,7 @@ onClickOutside(autoUpdateDropdown, () => closeAutoUpdateDropdown()) function openBundle() { if (!channel.value || channel.value.version.storage_provider === 'revert_to_builtin') return - if (channel.value.version.name === 'unknown') + if (isInternalVersionName(channel.value.version.name)) return router.push(`/app/${route.params.app}/bundle/${channel.value.version.id}`) } @@ -90,7 +90,7 @@ async function getChannel(force = false) { // Check if we already have this channel in the store if (!force && appDetailStore.currentChannelId === id.value && appDetailStore.currentChannel) { - channel.value = appDetailStore.currentChannel as any + channel.value = withBuiltinChannelVersion(appDetailStore.currentChannel as any) as any if (channel.value?.name) displayStore.setChannelName(String(channel.value.id), channel.value.name) displayStore.NavTitle = channel.value?.name ?? t('channel') @@ -136,7 +136,7 @@ async function getChannel(force = false) { return } - channel.value = data as unknown as Database['public']['Tables']['channels']['Row'] & Channel + channel.value = withBuiltinChannelVersion(data as any) as unknown as Database['public']['Tables']['channels']['Row'] & Channel // Store in appDetailStore appDetailStore.setChannel(id.value, channel.value) @@ -164,7 +164,7 @@ async function saveChannelChange(key: K, val: Chan return false // Validate version ID if updating version field - if (key === 'version' && (val === undefined || val === null || typeof val !== 'number')) { + if (key === 'version' && (val === undefined || (val !== null && typeof val !== 'number'))) { console.error('Invalid version ID:', val) toast.error(t('error-invalid-version')) return false @@ -260,28 +260,6 @@ async function handleVersionLink(appVersion: Database['public']['Tables']['app_v toast.success(t('linked-bundle')) } -async function getUnknownVersion(): Promise { - if (!channel.value) - return 0 - try { - const { data, error } = await supabase - .from('app_versions') - .select('id, app_id, name') - .eq('app_id', channel.value.version.app_id) - .eq('name', 'unknown') - .single() - if (error) { - console.error('no unknown version', error) - return 0 - } - return data.id - } - catch (error) { - console.error(error) - } - return 0 -} - async function handleUnlink() { if (!channel.value || !main.auth) return @@ -300,10 +278,7 @@ async function handleUnlink() { text: t('continue'), role: 'primary', handler: async () => { - const id = await getUnknownVersion() - if (!id) - return - saveChannelChange('version', id) + await saveChannelChange('version', null) }, }, ], @@ -328,22 +303,7 @@ async function handleRevert() { text: t('confirm'), role: 'primary', handler: async () => { - const { data: revertVersionId, error } = await supabase - .rpc('check_revert_to_builtin_version', { appid: packageId.value }) - - if (error) { - console.error('lazy load revertVersionId fail', error) - toast.error(t('error-revert-to-builtin')) - return - } - - if (!revertVersionId || typeof revertVersionId !== 'number') { - console.error('Invalid revert version ID:', revertVersionId) - toast.error(t('error-invalid-version')) - return - } - - await saveChannelChange('version', revertVersionId) + await saveChannelChange('version', null) }, }, ], diff --git a/src/pages/app/[app].device.[device].vue b/src/pages/app/[app].device.[device].vue index cc97c46f02..fb9f710d10 100644 --- a/src/pages/app/[app].device.[device].vue +++ b/src/pages/app/[app].device.[device].vue @@ -45,8 +45,6 @@ const canManageDevices = computedAsync(async () => { return await checkPermissions('app.manage_devices', { appId: packageId.value }) }, false) -const revertToNativeVersion = ref(null) - // Channel dropdown state const channelDropdown = ref() @@ -205,28 +203,12 @@ function minVersion(val: string, min = '4.6.99') { return greaterThan(parse(val), parse(min)) } -async function loadRevertToNativeVersion() { - if (revertToNativeVersion.value !== null) { - return - } - const { data: revertVersionId, error } = await supabase - .rpc('check_revert_to_builtin_version', { appid: packageId.value }) - - if (error) { - console.error('lazy load revertVersionId fail', error) - return - } - - revertToNativeVersion.value = revertVersionId -} - async function loadData() { isLoading.value = true await Promise.all([ getDevice(), getChannelOverride(), getChannels(), - loadRevertToNativeVersion(), ]) reloadCount.value += 1 isLoading.value = false diff --git a/src/services/supabase.ts b/src/services/supabase.ts index fa791d47e7..8bf669a2b7 100644 --- a/src/services/supabase.ts +++ b/src/services/supabase.ts @@ -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 as any)?.native_packages as any) ?? []) } interface Compatibility { diff --git a/src/services/versions.ts b/src/services/versions.ts index a98d309866..a04188e85f 100644 --- a/src/services/versions.ts +++ b/src/services/versions.ts @@ -7,12 +7,57 @@ import { i18n } from '~/modules/i18n' import { hideLoader, showLoader } from './loader' import { downloadUrl } from './supabase' +type AppVersionRow = Database['public']['Tables']['app_versions']['Row'] + export function isInternalVersionName(version: string) { if (!version) return false return version === 'builtin' || version === 'unknown' } +export function createBuiltinChannelVersion(channel: { + app_id: string + created_at: string | null + owner_org?: string | null +}): AppVersionRow { + return { + app_id: channel.app_id, + checksum: null, + cli_version: null, + comment: null, + created_at: channel.created_at, + deleted: false, + deleted_at: null, + external_url: null, + id: 0, + key_id: null, + link: null, + manifest: null, + manifest_count: 0, + min_update_version: null, + name: 'builtin', + native_packages: null, + owner_org: channel.owner_org ?? '', + r2_path: null, + session_key: null, + storage_provider: 'r2', + updated_at: null, + user_id: null, + } +} + +export function withBuiltinChannelVersion(channel: T): Omit & { version: AppVersionRow } { + return { + ...channel, + version: channel.version ?? createBuiltinChannelVersion(channel), + } +} + export async function openVersion(app: Database['public']['Tables']['app_versions']['Row']) { const { t } = i18n.global diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index acc0de68d6..8aaf3abd80 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -718,7 +718,7 @@ export type Database = { public: boolean rbac_id: string updated_at: string - version: number + version: number | null } Insert: { allow_dev?: boolean @@ -740,7 +740,7 @@ export type Database = { public?: boolean rbac_id?: string updated_at?: string - version: number + version?: number | null } Update: { allow_dev?: boolean @@ -762,7 +762,7 @@ export type Database = { public?: boolean rbac_id?: string updated_at?: string - version?: number + version?: number | null } Relationships: [ { @@ -3084,7 +3084,7 @@ export type Database = { } check_revert_to_builtin_version: { Args: { appid: string } - Returns: number + Returns: number | null } cleanup_expired_apikeys: { Args: never; Returns: undefined } cleanup_expired_demo_apps: { Args: never; Returns: undefined } diff --git a/supabase/functions/_backend/files/preview.ts b/supabase/functions/_backend/files/preview.ts index 49d6e66fe6..72d5d7e466 100644 --- a/supabase/functions/_backend/files/preview.ts +++ b/supabase/functions/_backend/files/preview.ts @@ -160,11 +160,12 @@ async function getChannelPreviewVersionId(c: Context, ap throw simpleError('channel_not_found', 'Channel not found', { channelId }) } - if (!Number.isSafeInteger(channel.version) || channel.version <= 0) { + const versionId = channel.version + if (!Number.isSafeInteger(versionId) || versionId === null || versionId <= 0) { throw simpleError('bundle_not_found', 'Bundle not found', { channelId }) } - return channel.version + return versionId } // Export the handler directly for use in the main app diff --git a/supabase/functions/_backend/plugins/stats.ts b/supabase/functions/_backend/plugins/stats.ts index 0d143f011e..f7fcd584a0 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, getAppOwnerPostgres, getAppVersionPostgres, getDrizzleClient, 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' @@ -95,17 +95,9 @@ async function post(c: Context, drizzleClient: ReturnType) + const appVersion = await getAppVersionPostgres(c, app_id, versionOnly, allowedDeleted, drizzleClient as ReturnType) if (!appVersion) { - const appVersion2 = await getAppVersionPostgres(c, app_id, 'unknown', true, drizzleClient as ReturnType) - if (appVersion2) { - appVersion = appVersion2 - cloudlog({ requestId: c.get('requestId'), message: `Version name ${version_name} not found, using unknown instead`, app_id, version_name }) - } - else { - backgroundTask(c, ensurePlaceholderVersions(c, app_id)) - return { success: false, error: 'version_not_found', message: 'Version not found', moreInfo: { app_id, version_name } } - } + return { success: false, error: 'version_not_found', message: 'Version not found', moreInfo: { app_id, version_name } } } // device.version = appVersion.id if (action === 'set' && !device.is_emulator && device.is_prod) { diff --git a/supabase/functions/_backend/public/app/demo.ts b/supabase/functions/_backend/public/app/demo.ts index d578251922..409afaf7d8 100644 --- a/supabase/functions/_backend/public/app/demo.ts +++ b/supabase/functions/_backend/public/app/demo.ts @@ -355,8 +355,6 @@ export async function createDemoApp(c: Context, body: Cr // Demo versions to create - simulates app development lifecycle const demoVersions: DemoVersion[] = [ - { name: 'unknown', daysAgo: 14 }, - { name: 'builtin', daysAgo: 14 }, { name: '1.0.0', daysAgo: 13, comment: 'Initial release' }, { name: '1.0.1', daysAgo: 10, comment: 'Bug fixes for login screen' }, { name: '1.1.0', daysAgo: 7, comment: 'Added dark mode support' }, @@ -364,15 +362,14 @@ export async function createDemoApp(c: Context, body: Cr { name: '1.2.0', daysAgo: 1, comment: 'New dashboard features', link: 'https://github.com/example/demo-app/pull/123' }, ] - // Create all versions with manifest and native_packages for real versions + // Create all versions with manifest and native_packages const versionInserts = demoVersions.map((v) => { - const isSystemVersion = v.name === 'unknown' || v.name === 'builtin' - const manifest = isSystemVersion ? null : getDemoManifest(v.name, appId) - const nativePackages = isSystemVersion ? null : getDemoNativePackages(v.name) + const manifest = getDemoManifest(v.name, appId) + const nativePackages = getDemoNativePackages(v.name) return { owner_org: body.owner_org, - deleted: isSystemVersion, + deleted: false, name: v.name, app_id: appId, created_at: daysAgoDate(v.daysAgo), @@ -417,9 +414,6 @@ export async function createDemoApp(c: Context, body: Cr const manifestInserts: Database['public']['Tables']['manifest']['Insert'][] = [] for (const version of demoVersions) { - if (version.name === 'unknown' || version.name === 'builtin') - continue - const versionId = versionMap.get(version.name) if (!versionId) continue diff --git a/supabase/functions/_backend/public/channel/post.ts b/supabase/functions/_backend/public/channel/post.ts index 1ba116708b..ce2287a7ad 100644 --- a/supabase/functions/_backend/public/channel/post.ts +++ b/supabase/functions/_backend/public/channel/post.ts @@ -5,7 +5,7 @@ import { BRES, simpleError } from '../../utils/hono.ts' import { cloudlogErr } from '../../utils/logging.ts' import { checkPermission } from '../../utils/rbac.ts' import { supabaseApikey, updateOrCreateChannel } from '../../utils/supabase.ts' -import { isValidAppId } from '../../utils/utils.ts' +import { isInternalVersionName, isValidAppId } from '../../utils/utils.ts' interface ChannelSet { app_id: string @@ -31,7 +31,7 @@ async function findVersion(c: Context, appID: string, version: string, ownerOrg: .eq('app_id', appID) .eq('name', version) .eq('owner_org', ownerOrg) - .eq('deleted', version === 'unknown') + .eq('deleted', false) .single() if (vError || !data) { cloudlogErr({ requestId: c.get('requestId'), message: 'Cannot find version', data: { appID, version, ownerOrg, vError } }) @@ -71,11 +71,13 @@ export async function post(c: Context, body: ChannelSet, ...(body.ios == null ? {} : { ios: body.ios }), ...(body.android == null ? {} : { android: body.android }), ...(inferredElectron == null ? {} : { electron: inferredElectron }), - version: -1, + version: null, owner_org: org.owner_org, } - channel.version = await findVersion(c, body.app_id, body.version ?? 'unknown', org.owner_org, apikey) + if (body.version && !isInternalVersionName(body.version)) + channel.version = await findVersion(c, body.app_id, body.version, org.owner_org, apikey) + await updateOrCreateChannel(c, channel) return c.json(BRES) } diff --git a/supabase/functions/_backend/triggers/cron_clear_versions.ts b/supabase/functions/_backend/triggers/cron_clear_versions.ts index 9343f60cf7..2e524db97d 100644 --- a/supabase/functions/_backend/triggers/cron_clear_versions.ts +++ b/supabase/functions/_backend/triggers/cron_clear_versions.ts @@ -112,19 +112,8 @@ app.post('/', middlewareAPISecret, async (c) => { if ((count ?? 0) > 0) { if (notFound) { - // set channel to unknown version where version is currently set - // find id of unknown version - const { data: unknownVersion, error: errorUnknownVersion } = await supabase.from('app_versions') - .select('id') - .eq('app_id', version.app_id) - .eq('name', 'unknown') - .single() - if (errorUnknownVersion) - throw simpleError('cannot_find_unknown_version', 'Cannot find unknown version for app_id', { error: errorUnknownVersion }) - if (!unknownVersion) - throw simpleError('cannot_find_unknown_version', 'Cannot find unknown version for app_id', { error: 'no unknown version found' }) await supabase.from('channels') - .update({ version: unknownVersion.id }) + .update({ version: null }) .eq('version', version.id) } else { diff --git a/supabase/functions/_backend/triggers/on_app_create.ts b/supabase/functions/_backend/triggers/on_app_create.ts index 45bde4fcbd..42aeeb7632 100644 --- a/supabase/functions/_backend/triggers/on_app_create.ts +++ b/supabase/functions/_backend/triggers/on_app_create.ts @@ -147,28 +147,6 @@ app.post('/', middlewareAPISecret, triggerValidator('apps', 'INSERT'), async (c) // Purge on-prem cache for this app to clear any stale responses await backgroundTask(c, purgeOnPremCache(c, record.app_id)) - const { error: dbVersionError } = await supabase - .from('app_versions') - .upsert([ - { - owner_org: ownerOrg, - deleted: true, - name: 'unknown', - app_id: record.app_id, - }, - { - owner_org: ownerOrg, - deleted: true, - name: 'builtin', - app_id: record.app_id, - }, - ], { onConflict: 'name,app_id', ignoreDuplicates: true }) - .select() - - if (dbVersionError) { - cloudlog({ requestId: c.get('requestId'), message: 'Error creating default versions', dbVersionError }) - } - // Skip onboarding emails for demo apps if (!isDemo && !isPendingOnboarding) { await backgroundTask(c, createIfNotExistStoreInfo(c, { diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index 2af0711135..7742f9502b 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -1,5 +1,5 @@ import type { Context } from 'hono' -import { and, eq, or, sql } from 'drizzle-orm' +import { and, eq, isNotNull, isNull, or, sql } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/node-postgres' import { alias } from 'drizzle-orm/pg-core' import { getRuntimeKey } from 'hono/adapter' @@ -446,12 +446,12 @@ function getSchemaUpdatesAlias(includeMetadata = false) { const { versionAlias, channelDevicesAlias, channelAlias } = getAlias() const versionSelect: any = { - id: sql`${versionAlias.id}`.as('vid'), - name: sql`${versionAlias.name}`.as('vname'), + id: sql`${versionAlias.id}`.as('vid'), + name: sql`CASE WHEN ${channelAlias.version} IS NULL THEN 'builtin' ELSE ${versionAlias.name} END`.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'), + storage_provider: sql`COALESCE(${versionAlias.storage_provider}, 'r2')`.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'), @@ -523,12 +523,16 @@ export function requestInfosChannelDevicePostgres( .select(selectShape) .from(channelDevicesAlias) .innerJoin(channelAlias, eq(channelDevicesAlias.channel_id, channelAlias.id)) - .innerJoin(versionAlias, activeChannelVersionJoin(channelAlias.version, versionAlias)) + .leftJoin(versionAlias, activeChannelVersionJoin(channelAlias.version, versionAlias)) const channelDevice = (includeManifest ? baseQuery.leftJoin(schema.manifest, eq(schema.manifest.app_version_id, versionAlias.id)) : baseQuery) - .where(and(eq(channelDevicesAlias.device_id, device_id), eq(channelDevicesAlias.app_id, app_id))) + .where(and( + eq(channelDevicesAlias.device_id, device_id), + eq(channelDevicesAlias.app_id, app_id), + or(isNull(channelAlias.version), isNotNull(versionAlias.id)), + )) .groupBy(channelDevicesAlias.device_id, channelDevicesAlias.app_id, channelAlias.id, versionAlias.id) .limit(1) cloudlog({ requestId: c.get('requestId'), message: 'channelDevice Query:', channelDeviceQuery: channelDevice.toSQL() }) @@ -556,12 +560,12 @@ export function requestInfosChannelPostgres( const baseQuery = drizzleClient .select(selectShape) .from(channelAlias) - .innerJoin(versionAlias, activeChannelVersionJoin(channelAlias.version, versionAlias)) + .leftJoin(versionAlias, activeChannelVersionJoin(channelAlias.version, versionAlias)) const channelQuery = (includeManifest ? baseQuery.leftJoin(schema.manifest, eq(schema.manifest.app_version_id, versionAlias.id)) : baseQuery) - .where( + .where(and( !defaultChannel ? and( eq(channelAlias.public, true), @@ -577,7 +581,8 @@ export function requestInfosChannelPostgres( eq(channelAlias.allow_device_self_set, true), ), ), - ) + or(isNull(channelAlias.version), isNotNull(versionAlias.id)), + )) .groupBy(channelAlias.id, versionAlias.id) .limit(1) cloudlog({ requestId: c.get('requestId'), message: 'channel Query:', channelQuery: channelQuery.toSQL() }) @@ -716,27 +721,6 @@ export async function getAppVersionPostgres( } } -export async function ensurePlaceholderVersions(c: Context, appId: string) { - let pgClient: ReturnType | undefined - try { - pgClient = getPgClient(c) - await pgClient.query( - `INSERT INTO public.app_versions (name, app_id, storage_provider) - VALUES ('builtin', $1, 'r2'), ('unknown', $1, 'r2') - ON CONFLICT (name, app_id) DO NOTHING`, - [appId], - ) - } - catch (e: unknown) { - logPgError(c, 'ensurePlaceholderVersions', e) - } - finally { - if (pgClient) { - await closeClient(c, pgClient) - } - } -} - export async function getAppVersionsByAppIdPg( c: Context, appId: string, diff --git a/supabase/functions/_backend/utils/postgres_schema.ts b/supabase/functions/_backend/utils/postgres_schema.ts index 487de5c7d9..c467f47e75 100644 --- a/supabase/functions/_backend/utils/postgres_schema.ts +++ b/supabase/functions/_backend/utils/postgres_schema.ts @@ -74,7 +74,7 @@ export const channels = pgTable('channels', { created_at: timestamp('created_at').notNull(), name: varchar('name').notNull(), app_id: varchar('app_id').notNull().references(() => apps.name), - version: bigint('version', { mode: 'number' }).notNull().references(() => app_versions.id), + version: bigint('version', { mode: 'number' }).references(() => app_versions.id, { onDelete: 'set null' }), created_by: uuid('created_by').notNull(), updated_at: timestamp('updated_at').defaultNow().notNull(), public: boolean('public').notNull().default(false), diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index acc0de68d6..8aaf3abd80 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -718,7 +718,7 @@ export type Database = { public: boolean rbac_id: string updated_at: string - version: number + version: number | null } Insert: { allow_dev?: boolean @@ -740,7 +740,7 @@ export type Database = { public?: boolean rbac_id?: string updated_at?: string - version: number + version?: number | null } Update: { allow_dev?: boolean @@ -762,7 +762,7 @@ export type Database = { public?: boolean rbac_id?: string updated_at?: string - version?: number + version?: number | null } Relationships: [ { @@ -3084,7 +3084,7 @@ export type Database = { } check_revert_to_builtin_version: { Args: { appid: string } - Returns: number + Returns: number | null } cleanup_expired_apikeys: { Args: never; Returns: undefined } cleanup_expired_demo_apps: { Args: never; Returns: undefined } diff --git a/supabase/migrations/20260519151250_remove_builtin_unknown_app_versions.sql b/supabase/migrations/20260519151250_remove_builtin_unknown_app_versions.sql new file mode 100644 index 0000000000..456e69b328 --- /dev/null +++ b/supabase/migrations/20260519151250_remove_builtin_unknown_app_versions.sql @@ -0,0 +1,223 @@ +-- Stop storing synthetic native/no-bundle markers as rows in app_versions. +-- A NULL channels.version now represents a channel pointing at the app's builtin/native bundle. + +ALTER TABLE "public"."channels" + DROP CONSTRAINT IF EXISTS "channels_version_fkey"; + +ALTER TABLE "public"."channels" + ALTER COLUMN "version" DROP NOT NULL; + +ALTER TABLE "public"."channels" + ADD CONSTRAINT "channels_version_fkey" + FOREIGN KEY ("version") + REFERENCES "public"."app_versions"("id") + ON DELETE SET NULL; + +UPDATE "public"."channels" AS "channels" +SET "version" = NULL +FROM "public"."app_versions" AS "app_versions" +WHERE "channels"."version" = "app_versions"."id" + AND "app_versions"."name" IN ('builtin', 'unknown'); + +DELETE FROM "public"."app_versions" +WHERE "name" IN ('builtin', 'unknown'); + +CREATE OR REPLACE FUNCTION "public"."check_revert_to_builtin_version"("appid" character varying) +RETURNS integer +LANGUAGE "plpgsql" +SET search_path = '' +AS $$ +BEGIN + PERFORM appid; + RETURN NULL::integer; +END; +$$; + +ALTER FUNCTION "public"."check_revert_to_builtin_version"("appid" character varying) OWNER TO "postgres"; +REVOKE ALL ON FUNCTION "public"."check_revert_to_builtin_version"("appid" character varying) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION "public"."check_revert_to_builtin_version"("appid" character varying) TO "anon"; +GRANT EXECUTE ON FUNCTION "public"."check_revert_to_builtin_version"("appid" character varying) TO "authenticated"; +GRANT EXECUTE ON FUNCTION "public"."check_revert_to_builtin_version"("appid" character varying) TO "service_role"; + +COMMENT ON FUNCTION "public"."check_revert_to_builtin_version"("appid" character varying) IS +'Legacy RPC kept for older clients. Native/builtin channel targets are represented by channels.version = NULL and this function must not recreate app_versions rows.'; + +CREATE OR REPLACE FUNCTION "public"."record_deployment_history"() +RETURNS trigger +LANGUAGE "plpgsql" +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + -- Native/builtin channel targets are stored as NULL and cannot be represented + -- in deploy_history.version_id. Record only concrete bundle deployments. + IF OLD.version IS DISTINCT FROM NEW.version AND NEW.version IS NOT NULL THEN + INSERT INTO public.deploy_history ( + channel_id, + app_id, + version_id, + owner_org, + created_by + ) + VALUES ( + NEW.id, + NEW.app_id, + NEW.version, + NEW.owner_org, + COALESCE(public.get_identity()::uuid, NEW.created_by) + ); + END IF; + + RETURN NEW; +END; +$$; + +ALTER FUNCTION "public"."record_deployment_history"() OWNER TO "postgres"; +REVOKE ALL ON FUNCTION "public"."record_deployment_history"() FROM PUBLIC; + +CREATE OR REPLACE FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid", "p_preserve_app_version_id" bigint) +RETURNS void +LANGUAGE "plpgsql" +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_app_id text; + v_owner_org uuid; + v_last_version text; + v_manifest_bundle_count bigint := 0; + v_channel_device_count bigint := 0; +BEGIN + SELECT app_id, owner_org + INTO v_app_id, v_owner_org + FROM public.apps + WHERE id = p_app_uuid; + + IF v_app_id IS NULL THEN + RETURN; + END IF; + + -- Production-safety guard (issue #2295). Refuse to delete data for + -- apps that look like real production. Any of these indicates the app + -- is not a fresh onboarding placeholder. + IF EXISTS ( + SELECT 1 FROM public.devices WHERE app_id = v_app_id + ) OR EXISTS ( + SELECT 1 FROM public.channel_devices WHERE app_id = v_app_id + ) OR EXISTS ( + SELECT 1 FROM public.deploy_history + WHERE app_id = v_app_id + AND ( + p_preserve_app_version_id IS NULL + OR version_id IS DISTINCT FROM p_preserve_app_version_id + ) + ) OR EXISTS ( + SELECT 1 FROM public.channels + WHERE app_id = v_app_id + AND version IS NOT NULL + AND ( + p_preserve_app_version_id IS NULL + OR version IS DISTINCT FROM p_preserve_app_version_id + ) + ) THEN + RAISE WARNING + 'clear_onboarding_app_data: refusing to clear app % -- production indicators present (see issue #2295)', + v_app_id; + RETURN; + END IF; + + DELETE FROM public.channel_devices + WHERE app_id = v_app_id + AND ( + p_preserve_app_version_id IS NULL + OR NOT EXISTS ( + SELECT 1 + FROM public.channels + WHERE channels.id = channel_devices.channel_id + AND channels.version = p_preserve_app_version_id + ) + ); + + DELETE FROM public.deploy_history + WHERE app_id = v_app_id + AND ( + p_preserve_app_version_id IS NULL + OR version_id IS DISTINCT FROM p_preserve_app_version_id + ); + + DELETE FROM public.channels + WHERE app_id = v_app_id + AND ( + p_preserve_app_version_id IS NULL + OR version IS DISTINCT FROM p_preserve_app_version_id + ); + + DELETE FROM public.devices + WHERE app_id = v_app_id; + + DELETE FROM public.app_versions_meta + WHERE app_id = v_app_id + AND ( + p_preserve_app_version_id IS NULL + OR id IS DISTINCT FROM p_preserve_app_version_id + ); + + DELETE FROM public.daily_version + WHERE app_id = v_app_id; + + DELETE FROM public.daily_bandwidth + WHERE app_id = v_app_id; + + DELETE FROM public.daily_storage + WHERE app_id = v_app_id; + + DELETE FROM public.daily_mau + WHERE app_id = v_app_id; + + DELETE FROM public.daily_build_time + WHERE app_id = v_app_id; + + DELETE FROM public.build_requests + WHERE app_id = v_app_id; + + DELETE FROM public.app_versions + WHERE app_id = v_app_id + AND ( + p_preserve_app_version_id IS NULL + OR id IS DISTINCT FROM p_preserve_app_version_id + ); + + IF p_preserve_app_version_id IS NOT NULL THEN + SELECT name, CASE WHEN manifest_count > 0 THEN 1 ELSE 0 END + INTO v_last_version, v_manifest_bundle_count + FROM public.app_versions + WHERE id = p_preserve_app_version_id + AND app_id = v_app_id + AND deleted IS FALSE; + + SELECT COUNT(*)::bigint + INTO v_channel_device_count + FROM public.channel_devices + INNER JOIN public.channels + ON channels.id = channel_devices.channel_id + WHERE channel_devices.app_id = v_app_id + AND channels.version = p_preserve_app_version_id; + END IF; + + UPDATE public.apps + SET + channel_device_count = v_channel_device_count, + manifest_bundle_count = v_manifest_bundle_count, + last_version = v_last_version + WHERE id = p_app_uuid; + + IF v_owner_org IS NOT NULL THEN + DELETE FROM public.app_metrics_cache + WHERE org_id = v_owner_org; + END IF; +END; +$$; + +ALTER FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid", "p_preserve_app_version_id" bigint) OWNER TO "postgres"; +REVOKE ALL ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid", "p_preserve_app_version_id" bigint) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION "public"."clear_onboarding_app_data"("p_app_uuid" "uuid", "p_preserve_app_version_id" bigint) TO "service_role"; diff --git a/supabase/seed.sql b/supabase/seed.sql index 5dcea00007..52cbdaad34 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -566,21 +566,13 @@ BEGIN (NOW(), 'com.test2.app', '', 'Test2 App', '1.0.0', NOW(), '34a8c55d-2d0f-4652-a43f-684c7a9403ac', '6f0d1a2e-59ed-4769-b9d7-4d9615b28fe5'); INSERT INTO "public"."app_versions" ("id", "created_at", "app_id", "name", "r2_path", "updated_at", "deleted", "external_url", "checksum", "session_key", "storage_provider", "owner_org", "user_id", "comment", "link") VALUES - (1, NOW(), 'com.demo.app', 'builtin', NULL, NOW(), 't', NULL, NULL, NULL, 'supabase', '046a36ac-e03c-4590-9257-bd6c9dba9ee8', NULL, NULL, NULL), - (2, NOW(), 'com.demo.app', 'unknown', NULL, NOW(), 't', NULL, NULL, NULL, 'supabase', '046a36ac-e03c-4590-9257-bd6c9dba9ee8', NULL, NULL, NULL), (3, NOW(), 'com.demo.app', '1.0.0', 'orgs/046a36ac-e03c-4590-9257-bd6c9dba9ee8/apps/com.demo.app/1.0.0.zip', NOW(), 'f', NULL, '3885ee49', NULL, 'r2', '046a36ac-e03c-4590-9257-bd6c9dba9ee8', '6aa76066-55ef-4238-ade6-0b32334a4097', 'its a test', 'https://capgo.app'), (4, NOW(), 'com.demo.app', '1.0.1', 'orgs/046a36ac-e03c-4590-9257-bd6c9dba9ee8/apps/com.demo.app/1.0.1.zip', NOW(), 'f', NULL, '', NULL, 'r2-direct', '046a36ac-e03c-4590-9257-bd6c9dba9ee8', '6aa76066-55ef-4238-ade6-0b32334a4097', 'its a test', 'https://capgo.app'), (5, NOW(), 'com.demo.app', '1.361.0', 'orgs/046a36ac-e03c-4590-9257-bd6c9dba9ee8/apps/com.demo.app/1.361.0.zip', NOW(), 'f', NULL, '9d4f798a', NULL, 'r2', '046a36ac-e03c-4590-9257-bd6c9dba9ee8', '6aa76066-55ef-4238-ade6-0b32334a4097', 'its a test', 'https://capgo.app'), (6, NOW(), 'com.demo.app', '1.360.0', 'orgs/046a36ac-e03c-4590-9257-bd6c9dba9ee8/apps/com.demo.app/1.360.0.zip', NOW(), 'f', NULL, '44913a9f', NULL, 'r2', '046a36ac-e03c-4590-9257-bd6c9dba9ee8', '6aa76066-55ef-4238-ade6-0b32334a4097', 'its a test', 'https://capgo.app'), (7, NOW(), 'com.demo.app', '1.359.0', 'orgs/046a36ac-e03c-4590-9257-bd6c9dba9ee8/apps/com.demo.app/1.359.0.zip', NOW(), 'f', NULL, '9f74e70a', NULL, 'r2', '046a36ac-e03c-4590-9257-bd6c9dba9ee8', '6aa76066-55ef-4238-ade6-0b32334a4097', 'its a test', 'https://capgo.app'), - (8, NOW(), 'com.demoadmin.app', 'builtin', NULL, NOW(), 't', NULL, NULL, NULL, 'supabase', '22dbad8a-b885-4309-9b3b-a09f8460fb6d', NULL, NULL, NULL), - (9, NOW(), 'com.demoadmin.app', 'unknown', NULL, NOW(), 't', NULL, NULL, NULL, 'supabase', '22dbad8a-b885-4309-9b3b-a09f8460fb6d', NULL, NULL, NULL), (10, NOW(), 'com.demoadmin.app', '1.0.0', 'orgs/22dbad8a-b885-4309-9b3b-a09f8460fb6d/apps/com.demoadmin.app/1.0.0.zip', NOW(), 'f', NULL, 'admin123', NULL, 'r2', '22dbad8a-b885-4309-9b3b-a09f8460fb6d', 'c591b04e-cf29-4945-b9a0-776d0672061a', 'admin app test version', 'https://capgo.app'), - (11, NOW(), 'com.stats.app', 'builtin', NULL, NOW(), 't', NULL, NULL, NULL, 'supabase', 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e', NULL, NULL, NULL), - (12, NOW(), 'com.stats.app', 'unknown', NULL, NOW(), 't', NULL, NULL, NULL, 'supabase', 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e', NULL, NULL, NULL), (13, NOW(), 'com.stats.app', '1.0.0', 'orgs/b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e/apps/com.stats.app/1.0.0.zip', NOW(), 'f', NULL, 'stats123', NULL, 'r2', 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e', '7a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d', 'stats test version', 'https://capgo.app'), - (14, now(), 'com.test2.app', 'builtin', NULL, now(), 't', NULL, NULL, NULL, 'supabase', '34a8c55d-2d0f-4652-a43f-684c7a9403ac', NULL, NULL, NULL), - (15, now(), 'com.test2.app', 'unknown', NULL, now(), 't', NULL, NULL, NULL, 'supabase', '34a8c55d-2d0f-4652-a43f-684c7a9403ac', NULL, NULL, NULL), (16, now(), 'com.test2.app', '1.0.0', 'orgs/34a8c55d-2d0f-4652-a43f-684c7a9403ac/apps/com.test2.app/1.0.0.zip', now(), 'f', NULL, 'test2123', NULL, 'r2', '34a8c55d-2d0f-4652-a43f-684c7a9403ac', '6f0d1a2e-59ed-4769-b9d7-4d9615b28fe5', 'test2 app version', 'https://capgo.app'); INSERT INTO "public"."app_versions_meta" ("id", "created_at", "app_id", "updated_at", "checksum", "size") VALUES @@ -809,7 +801,7 @@ DECLARE WHEN p_org_id IS NULL THEN 'Demo org' ELSE concat('Seeded Org ', p_app_id) END; - builtin_version_id bigint; unknown_version_id bigint; v1_0_1_version_id bigint; v1_0_0_version_id bigint; v1_361_0_version_id bigint; v1_360_0_version_id bigint; v1_359_0_version_id bigint; + v1_0_1_version_id bigint; v1_0_0_version_id bigint; v1_361_0_version_id bigint; v1_360_0_version_id bigint; v1_359_0_version_id bigint; production_channel_id bigint; beta_channel_id bigint; development_channel_id bigint; no_access_channel_id bigint; electron_only_channel_id bigint; BEGIN PERFORM pg_advisory_xact_lock(hashtext(p_app_id)); @@ -900,8 +892,6 @@ BEGIN WITH version_inserts AS ( INSERT INTO public.app_versions (created_at, app_id, name, r2_path, updated_at, deleted, external_url, checksum, storage_provider, owner_org, comment, link, user_id) VALUES - (NOW(), p_app_id, 'builtin', NULL, NOW(), 't', NULL, NULL, 'supabase', org_id, NULL, NULL, NULL), - (NOW(), p_app_id, 'unknown', NULL, NOW(), 't', NULL, NULL, 'supabase', org_id, NULL, NULL, NULL), (NOW(), p_app_id, '1.0.1', 'orgs/'||org_id||'/apps/'||p_app_id||'/1.0.1.zip', NOW(), 'f', NULL, '', 'r2-direct', org_id, 'Bug fixes and minor improvements', 'https://github.com/Cap-go/capgo/releases/tag/v1.0.1', user_id), (NOW(), p_app_id, '1.0.0', 'orgs/'||org_id||'/apps/'||p_app_id||'/1.0.0.zip', NOW(), 'f', NULL, '3885ee49', 'r2', org_id, 'Initial release', 'https://github.com/Cap-go/capgo/releases/tag/v1.0.0', user_id), (NOW(), p_app_id, '1.361.0', 'orgs/'||org_id||'/apps/'||p_app_id||'/1.361.0.zip', NOW(), 'f', NULL, '9d4f798a', 'r2', org_id, 'Major version update with new features', 'https://github.com/Cap-go/capgo/releases/tag/v1.361.0', user_id), @@ -909,8 +899,8 @@ BEGIN (NOW(), p_app_id, '1.359.0', 'orgs/'||org_id||'/apps/'||p_app_id||'/1.359.0.zip', NOW(), 'f', NULL, '9f74e70a', 'r2', org_id, 'Stability improvements', 'https://github.com/Cap-go/capgo/releases/tag/v1.359.0', user_id) RETURNING id, name ) - SELECT MAX(CASE WHEN name='builtin' THEN id END), MAX(CASE WHEN name='unknown' THEN id END), MAX(CASE WHEN name='1.0.1' THEN id END), MAX(CASE WHEN name='1.0.0' THEN id END), MAX(CASE WHEN name='1.361.0' THEN id END), MAX(CASE WHEN name='1.360.0' THEN id END), MAX(CASE WHEN name='1.359.0' THEN id END) - INTO builtin_version_id, unknown_version_id, v1_0_1_version_id, v1_0_0_version_id, v1_361_0_version_id, v1_360_0_version_id, v1_359_0_version_id FROM version_inserts; + SELECT MAX(CASE WHEN name='1.0.1' THEN id END), MAX(CASE WHEN name='1.0.0' THEN id END), MAX(CASE WHEN name='1.361.0' THEN id END), MAX(CASE WHEN name='1.360.0' THEN id END), MAX(CASE WHEN name='1.359.0' THEN id END) + INTO v1_0_1_version_id, v1_0_0_version_id, v1_361_0_version_id, v1_360_0_version_id, v1_359_0_version_id FROM version_inserts; WITH channel_inserts AS ( INSERT INTO public.channels (created_at, name, app_id, version, updated_at, public, disable_auto_update_under_native, disable_auto_update, ios, android, electron, allow_device_self_set, allow_emulator, allow_device, allow_dev, allow_prod, created_by, owner_org) VALUES @@ -930,7 +920,7 @@ BEGIN (NOW() - interval '5 days', NOW() - interval '5 days', development_channel_id, p_app_id, v1_359_0_version_id, NOW() - interval '5 days', org_id, user_id), (NOW() - interval '3 days', NOW() - interval '3 days', no_access_channel_id, p_app_id, v1_361_0_version_id, NOW() - interval '3 days', org_id, user_id), (NOW() - interval '2 days', NOW() - interval '2 days', electron_only_channel_id, p_app_id, v1_360_0_version_id, NOW() - interval '2 days', org_id, user_id); - PERFORM builtin_version_id, unknown_version_id, v1_0_1_version_id, v1_360_0_version_id; + PERFORM v1_0_1_version_id, v1_360_0_version_id; END; $$; diff --git a/supabase/tests/18_test_utility_functions_extended.sql b/supabase/tests/18_test_utility_functions_extended.sql index a3287c0154..12072d845a 100644 --- a/supabase/tests/18_test_utility_functions_extended.sql +++ b/supabase/tests/18_test_utility_functions_extended.sql @@ -327,8 +327,8 @@ SELECT tests.authenticate_as('test_user'); SELECT ok( - check_revert_to_builtin_version('com.demo.app') > 0, - 'check_revert_to_builtin_version test - returns version id' + check_revert_to_builtin_version('com.demo.app') IS NULL, + 'check_revert_to_builtin_version test - returns NULL for native rollback' ); -- Test check_revert_to_builtin_version negative case (skipped due to missing app_versions table in test environment) diff --git a/supabase/tests/33_test_rbac_phase1.sql b/supabase/tests/33_test_rbac_phase1.sql index 980243a715..5bdc406111 100644 --- a/supabase/tests/33_test_rbac_phase1.sql +++ b/supabase/tests/33_test_rbac_phase1.sql @@ -153,7 +153,7 @@ SELECT channel_rbac_id, 'rbac-channel', app_rbac, - 1, + NULL::bigint, org_rbac, admin_user FROM seed_data diff --git a/tests/bundle.test.ts b/tests/bundle.test.ts index eae20678e4..637eea3738 100644 --- a/tests/bundle.test.ts +++ b/tests/bundle.test.ts @@ -207,17 +207,7 @@ describe('[PUT] /bundle operations - Set bundle to channel', () => { const version = await createAppVersions('1.0.0-test-channel', APPNAME) versionId = version.id - // Get the unknown version for this app const supabase = getSupabaseClient() - const { data: unknownVersion } = await supabase - .from('app_versions') - .select('id') - .eq('app_id', APPNAME) - .eq('name', 'unknown') - .single() - if (!unknownVersion) { - throw new Error('Failed to find unknown version') - } // Create a test channel using proper seeded values const { data: channel, error } = await supabase @@ -225,7 +215,7 @@ describe('[PUT] /bundle operations - Set bundle to channel', () => { .insert({ name: 'test-channel', app_id: APPNAME, - version: unknownVersion.id, // Use app's unknown version + version: null, created_by: '6aa76066-55ef-4238-ade6-0b32334a4097', // test@capgo.app user from seed owner_org: '046a36ac-e03c-4590-9257-bd6c9dba9ee8', // Demo org from seed }) diff --git a/tests/channel-post.unit.test.ts b/tests/channel-post.unit.test.ts index 7769dee2eb..b03a4743b5 100644 --- a/tests/channel-post.unit.test.ts +++ b/tests/channel-post.unit.test.ts @@ -28,6 +28,7 @@ vi.mock('../supabase/functions/_backend/utils/supabase.ts', () => ({ })) vi.mock('../supabase/functions/_backend/utils/utils.ts', () => ({ + isInternalVersionName: (version: string) => version === 'builtin' || version === 'unknown', isValidAppId, })) diff --git a/tests/cli-channel.test.ts b/tests/cli-channel.test.ts index f1d35b33b1..3bb7f65e52 100644 --- a/tests/cli-channel.test.ts +++ b/tests/cli-channel.test.ts @@ -162,7 +162,7 @@ describe('tests CLI channel commands', () => { .single() .throwOnError() expect(error).toBeNull() - expect(data?.version.name).toBe(bundle) + expect(data?.version?.name).toBe(bundle) }) it.concurrent('should fail to set bundle for invalid channel name', async () => { diff --git a/tests/stats.test.ts b/tests/stats.test.ts index 0c3f04e31a..e6fdbc7844 100644 --- a/tests/stats.test.ts +++ b/tests/stats.test.ts @@ -183,15 +183,13 @@ describe('[POST] /stats', () => { await getSupabaseClient().from('devices').delete().eq('device_id', uuid).eq('app_id', APP_NAME_STATS) }) - it('uses deleted unknown placeholder instead of scheduling repeated placeholder inserts', async () => { + it.concurrent('does not recreate unknown placeholder rows for missing versions', async () => { const uuid = randomUUID().toLowerCase() const appId = `${APP_NAME}.deleted.unknown.${randomUUID().split('-')[0]}` await resetAndSeedAppData(appId) await resetAndSeedAppDataStats(appId) try { - await createAppVersions('unknown', appId, { deleted: true }) - const baseData = getBaseData(appId) as StatsPayload baseData.device_id = uuid baseData.action = 'set' @@ -200,7 +198,16 @@ describe('[POST] /stats', () => { const response = await postStats(baseData) expect(response.status).toBe(200) - expect(await response.json()).toEqual({ status: 'ok' }) + expect(await response.json()).toMatchObject({ error: 'version_not_found' }) + + const { count, error } = await getSupabaseClient() + .from('app_versions') + .select('id', { count: 'exact', head: true }) + .eq('app_id', appId) + .eq('name', 'unknown') + + expect(error).toBeNull() + expect(count).toBe(0) } finally { await getSupabaseClient().from('devices').delete().eq('device_id', uuid).eq('app_id', appId) diff --git a/tests/updates.test.ts b/tests/updates.test.ts index d41d20df8d..19044b3281 100644 --- a/tests/updates.test.ts +++ b/tests/updates.test.ts @@ -266,7 +266,7 @@ describe('[POST] /updates', () => { expect(json.checksum).toBe(expectedFallbackJson.checksum) }) - it('keeps builtin channel targets addressable', async () => { + it('keeps native channel targets addressable without app_versions placeholders', async () => { const supabase = getSupabaseClient() const { data: productionChannel } = await supabase .from('channels') @@ -276,17 +276,9 @@ describe('[POST] /updates', () => { .single() .throwOnError() - const { data: builtinVersion } = await supabase - .from('app_versions') - .select('id') - .eq('app_id', APP_NAME_UPDATE) - .eq('name', 'builtin') - .single() - .throwOnError() - await supabase .from('channels') - .update({ version: builtinVersion.id }) + .update({ version: null }) .eq('id', productionChannel.id) .eq('app_id', APP_NAME_UPDATE) .throwOnError() From a996f1f71abd05f4e540d2b07a9e6473df92bfb4 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 20 May 2026 12:51:55 +0200 Subject: [PATCH 2/3] fix(db): keep merged demo reset migration immutable --- .../20260519123613_safe_demo_data_reset.sql | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/supabase/migrations/20260519123613_safe_demo_data_reset.sql b/supabase/migrations/20260519123613_safe_demo_data_reset.sql index 83d3584dd7..1c0347a79d 100644 --- a/supabase/migrations/20260519123613_safe_demo_data_reset.sql +++ b/supabase/migrations/20260519123613_safe_demo_data_reset.sql @@ -233,7 +233,7 @@ BEGIN SELECT 1 FROM "public"."app_versions" av WHERE av."app_id" = v_app_id - AND av."name" NOT IN ('1.0.0', '1.0.1', '1.1.0', '1.1.1', '1.2.0') + AND av."name" NOT IN ('unknown', 'builtin', '1.0.0', '1.0.1', '1.1.0', '1.1.1', '1.2.0') ) AND NOT EXISTS ( SELECT 1 @@ -556,6 +556,16 @@ BEGIN PERFORM "public"."claim_legacy_onboarding_demo_data"(p_app_uuid); + -- unknown/builtin are system placeholders maintained by app creation. They + -- are allowed in demo-shaped legacy apps, but must never be demo-owned rows. + DELETE FROM "public"."onboarding_demo_data" odd + USING "public"."app_versions" av + WHERE odd."app_id" = v_app_id + AND odd."relation_name" IN ('app_versions', 'app_versions_meta') + AND odd."row_key" = av."id"::text + AND av."app_id" = v_app_id + AND av."name" IN ('unknown', 'builtin'); + -- Refuse to delete tracked parents when any untracked child row points at -- them. Without these guards, ON DELETE CASCADE could remove real data that -- a user attached to a demo-created version or channel. From 474d9424a9fff8b2709275fe43910f4f62f913a8 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 20 May 2026 13:42:05 +0200 Subject: [PATCH 3/3] fix(db): remove placeholder version recreate paths --- scripts/fix_app_stats_day_1.mjs | 2 +- scripts/fix_app_versions.mjs | 75 ------------------- scripts/fix_app_versions_meta.mjs | 2 +- scripts/fix_app_versions_trigger.mjs | 2 +- .../_backend/triggers/on_version_create.ts | 9 +-- 5 files changed, 5 insertions(+), 85 deletions(-) delete mode 100644 scripts/fix_app_versions.mjs diff --git a/scripts/fix_app_stats_day_1.mjs b/scripts/fix_app_stats_day_1.mjs index e218b6a9b2..899ebd2b89 100644 --- a/scripts/fix_app_stats_day_1.mjs +++ b/scripts/fix_app_stats_day_1.mjs @@ -1,4 +1,4 @@ -// list all apps in supabase and create version unknown for each +// Backfill first-day app statistics from stored app versions. import { createClient } from '@supabase/supabase-js' const supabaseUrl = 'https://sb.capgo.app' diff --git a/scripts/fix_app_versions.mjs b/scripts/fix_app_versions.mjs deleted file mode 100644 index f64fbec2e1..0000000000 --- a/scripts/fix_app_versions.mjs +++ /dev/null @@ -1,75 +0,0 @@ -// list all apps in supabase and create version unknown for each -import { createClient } from '@supabase/supabase-js' - -const supabaseUrl = 'https://***.supabase.co' -const supabaseAnonKey = '***' - -export function useSupabase() { - const options = { - // const options: SupabaseClientOptions = { - autoRefreshToken: true, - persistSession: true, - detectSessionInUrl: false, - } - return createClient(supabaseUrl, supabaseAnonKey, options) -} - -async function fix_apps() { - const supabase = useSupabase() - - const { data } = await supabase - .from('apps') - // .from('apps') - .select() - - if (!data || !data.length) { - console.error('No apps found') - return - } - - for (const app of data) { - console.log('app', app.app_id) - const { data } = await supabase - .from('app_versions') - // .from('app_versions') - .select() - .eq('app_id', app.app_id) - .eq('name', 'unknown') - .single() - if (!data) { - const { error: dbVersionUError } = await supabase - .from('app_versions') - // .from('app_versions') - .insert({ - user_id: app.user_id, - deleted: true, - name: 'unknown', - app_id: app.app_id, - }, { returning: 'minimal' }) - if (dbVersionUError) - console.log('Cannot create version unknown', app.app_id, dbVersionUError) - } - const { data: data2 } = await supabase - .from('app_versions') - // .from('app_versions') - .select() - .eq('app_id', app.app_id) - .eq('name', 'builtin') - .single() - if (!data2) { - const { error: dbVersionUError } = await supabase - .from('app_versions') - // .from('app_versions') - .insert({ - user_id: app.user_id, - deleted: true, - name: 'builtin', - app_id: app.app_id, - }, { returning: 'minimal' }) - if (dbVersionUError) - console.log('Cannot create version unknown', app.app_id, dbVersionUError) - } - } -} - -fix_apps() diff --git a/scripts/fix_app_versions_meta.mjs b/scripts/fix_app_versions_meta.mjs index 9ad9dee121..0bf3e0ae62 100644 --- a/scripts/fix_app_versions_meta.mjs +++ b/scripts/fix_app_versions_meta.mjs @@ -1,4 +1,4 @@ -// list all apps in supabase and create version unknown for each +// Backfill metadata size for deleted app versions. import { createClient } from '@supabase/supabase-js' const supabaseUrl = 'https://***.supabase.co' diff --git a/scripts/fix_app_versions_trigger.mjs b/scripts/fix_app_versions_trigger.mjs index 7b7cb48419..94ea4adf9f 100644 --- a/scripts/fix_app_versions_trigger.mjs +++ b/scripts/fix_app_versions_trigger.mjs @@ -1,4 +1,4 @@ -// list all apps in supabase and create version unknown for each +// Backfill app_stats device counts for recently active apps. import { createClient } from '@supabase/supabase-js' const supabaseUrl = 'https://***.supabase.co' diff --git a/supabase/functions/_backend/triggers/on_version_create.ts b/supabase/functions/_backend/triggers/on_version_create.ts index 05aca77a05..21f91c90d7 100644 --- a/supabase/functions/_backend/triggers/on_version_create.ts +++ b/supabase/functions/_backend/triggers/on_version_create.ts @@ -11,9 +11,6 @@ import { supabaseAdmin } from '../utils/supabase.ts' import { sendEventToTracking } from '../utils/tracking.ts' import { backgroundTask } from '../utils/utils.ts' -// Special bundle names that should not trigger email notifications -const SKIP_EMAIL_BUNDLE_NAMES = ['unknown', 'builtin'] - export const app = new Hono() app.post('/', middlewareAPISecret, triggerValidator('app_versions', 'INSERT'), async (c) => { @@ -25,11 +22,9 @@ app.post('/', middlewareAPISecret, triggerValidator('app_versions', 'INSERT'), a throw simpleError('no_id', 'No id', { record }) } - // Skip email notifications for special system bundles (unknown, builtin) - let shouldSkipNotifications = SKIP_EMAIL_BUNDLE_NAMES.includes(record.name) + let shouldSkipNotifications = false - // Also skip notifications for demo apps. - if (!shouldSkipNotifications && await isDemoApp(c, record.app_id)) { + if (await isDemoApp(c, record.app_id)) { cloudlog({ requestId: c.get('requestId'), message: 'Demo app detected, skipping email notifications' }) shouldSkipNotifications = true }