feat: paused project restore window copy and backup downloads (#48279)

Shows the restore deadline as a date on the paused-project screens, and
offers backup downloads while a paused project is still restorable
(previously only after the restore window ended).

Depends on a backend change — keep as draft until that is live.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Extended the paused-project restore window from 90 days to up to 1
year.
* Added clearer, downloadable options for database backups and storage
objects while a project is paused.
* Paused-project screens now show an “available until” date when
applicable (and updated resume guidance).
* **Documentation**
* Updated platform and troubleshooting guides to reflect the new 1-year
restore window and post-window recovery limitations.
* **Bug Fixes**
* Standardized restore-window wording across the pause confirmation and
paused-state UI.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Crispy authored and GitHub committed 2026-07-24 19:45:34 +07:00
1 parent 34e2585756
commit 8c092185ae
10 files changed
+244 -228

No files matched your search

@@ -28,7 +28,7 @@ After receiving an initial email warning, the pause can be prevented by taking o
## Restoring a paused project
You can restore a paused project for up to 90 days after it was paused:
You can restore a paused project for up to 1 year after it was paused:
1. Open the [Supabase Dashboard](/dashboard/organizations)
2. Select the organization, followed by the paused project
@@ -36,9 +36,9 @@ You can restore a paused project for up to 90 days after it was paused:
The project will return to its previous state, including data and configurations.
### 90-day window to restore
### Restore window [#90-day-window-to-restore]
Once the project is paused, there is a 90-day window to restore the project on the platform from within Supabase Studio. The 90-day window allows Supabase to introduce platform changes that may not be backward compatible with older backups. Unlike active projects, static backups can't be updated to accommodate such changes.
Once the project is paused, there is a 1-year window to restore the project on the platform from within Supabase Studio. The time limit exists because backups are only retained for a limited period, and platform changes may not be backward compatible with older backups. Unlike active projects, static backups can't be updated to accommodate such changes.
## Preventing automatic project pausing
@@ -94,11 +94,11 @@ When upgrading, the Supabase platform will "right-size" your disk based on the c
### Time limits
Starting from 2024-06-24, when a project is paused, users then have a 90-day window to restore the project on the platform from within Supabase Studio.
When a project is paused, users have a 1-year window to restore the project on the platform from within Supabase Studio.
The 90-day window allows Supabase to introduce platform changes that may not be backwards compatible with older backups. Unlike active projects, static backups can't be updated to accommodate such changes.
The restore window exists because backups are only retained for a limited period, and platform changes may not be backwards compatible with older backups. Unlike active projects, static backups can't be updated to accommodate such changes.
During the 90-day restore window a paused project can be restored to the platform with a single button click from [Studio's dashboard page](/dashboard/projects).
During the restore window a paused project can be restored to the platform with a single button click from [Studio's dashboard page](/dashboard/projects).
<Image
alt="Project Paused: 90 Days Remaining"
@@ -107,7 +107,7 @@ During the 90-day restore window a paused project can be restored to the platfor
src="/docs/img/guides/platform/paused-90-day.png"
/>
After the 90-day restore window, you can download your project's backup file, and Storage objects from the project dashboard. You can restore the data in the following ways:
After the restore window, you can download your project's backup file, and Storage objects from the project dashboard. You can restore the data in the following ways:
- [Restore a backup to a new Supabase project](/docs/guides/platform/migrating-within-supabase/dashboard-restore)
- [Restore a backup locally](/docs/guides/local-development/restoring-downloaded-backup)
@@ -119,7 +119,7 @@ After the 90-day restore window, you can download your project's backup file, an
height={306}
/>
If you upgrade to a paid plan while your project is paused within the 90-day restore window, any expired one-click restore options are reenabled. Since the backup was taken outside the backwards compatibility window, it may fail to restore. If you have a problem restoring your backup after upgrading, contact [Support](/support).
If you upgrade to a paid plan while your project is paused, any expired one-click restore options are reenabled. Since the backup was taken outside the backwards compatibility window, it may fail to restore. If you have a problem restoring your backup after upgrading, contact [Support](/support).
<Image
alt="Project Paused: Paid Tier Restore"
@@ -1,15 +1,15 @@
---
title = "How To Restore Project After 90-Day Pause"
title = "How To Restore a Project Paused for More Than 1 Year"
topics = [ "platform" ]
keywords = [ "pause", "restore", "90 days", "project", "backup", "migration", "storage" ]
keywords = [ "pause", "restore", "1 year", "90 days", "project", "backup", "migration", "storage" ]
---
Projects paused for more than 90 days can no longer be restored through Supabase Studio. You can still recover your data by downloading the available backups and migrating them to a new project.
Projects paused for more than 1 year can no longer be restored through Supabase Studio. You can still recover your data by downloading the available backups and migrating them to a new project.
Both the database backup and Storage objects can be downloaded from the [Project Overview](/dashboard/project/_) section in Supabase Studio before the project is deleted.
<Image
alt="Restore project after 90-day pause"
alt="Restore project after pause"
src={{
dark: '/docs/img/restore-after-90-day-dark.png',
light: '/docs/img/restore-after-90-day-light.png',
@@ -110,7 +110,7 @@ export const PauseProjectButton = () => {
<AlertDialogTitle>Pause {entityLabel}?</AlertDialogTitle>
<AlertDialogDescription>
This {entityLabel} will be unavailable while paused. Paused {entityLabel} can be
resumed for 90 days. After that, backups remain available to download.
resumed for up to 1 year. After that, backups remain available to download.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -0,0 +1,154 @@
import { useParams } from 'common'
import { Database, Storage } from 'icons'
import { ChevronDown, Download } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from 'ui'
import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
import { useBackupDownloadMutation } from '@/data/database/backup-download-mutation'
import { useProjectPauseStatusQuery } from '@/data/projects/project-pause-status-query'
import { useStorageArchiveCreateMutation } from '@/data/storage/storage-archive-create-mutation'
import { useStorageArchiveQuery } from '@/data/storage/storage-archive-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from '@/lib/constants'
export const DownloadBackupsSection = () => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const [toastId, setToastId] = useState<string | number>()
const [refetchInterval, setRefetchInterval] = useState<number | false>(false)
const dbVersion = project?.dbVersion?.replace('supabase-postgres-', '')
const { data: pauseStatus } = useProjectPauseStatusQuery(
{ ref },
{ enabled: project?.status === PROJECT_STATUS.INACTIVE }
)
const latestBackup = pauseStatus?.latest_downloadable_backup_id
const { data: storageArchive, isSuccess: isStorageArchiveSuccess } = useStorageArchiveQuery(
{ projectRef: ref },
{
refetchInterval,
refetchOnWindowFocus: false,
}
)
useEffect(() => {
if (!isStorageArchiveSuccess) return
if (storageArchive.fileUrl && refetchInterval !== false) {
toast.success('Downloading storage objects', { id: toastId })
setToastId(undefined)
setRefetchInterval(false)
downloadStorageArchive(storageArchive.fileUrl)
}
}, [isStorageArchiveSuccess, storageArchive, refetchInterval])
const storageArchiveUrl = storageArchive?.fileUrl
const { mutate: downloadBackup } = useBackupDownloadMutation({
onSuccess: (res) => {
const { fileUrl } = res
// Trigger browser download by create,trigger and remove tempLink
const tempLink = document.createElement('a')
tempLink.href = fileUrl
document.body.appendChild(tempLink)
tempLink.click()
document.body.removeChild(tempLink)
},
})
const { mutate: createStorageArchive } = useStorageArchiveCreateMutation({
onSuccess: () => {
const toastId = toast.loading(
'Retrieving storage archive. This may take a few minutes depending on the size of your storage objects.'
)
setToastId(toastId)
setRefetchInterval(5000)
},
})
const onSelectDownloadBackup = () => {
if (ref === undefined) return console.error('Project ref is required')
if (!latestBackup) return toast.error('No backups available for download')
const toastId = toast.loading('Fetching database backup')
downloadBackup(
{
ref,
backup: {
id: latestBackup,
},
},
{
onSuccess: () => {
toast.success('Downloading database backup', { id: toastId })
},
}
)
}
const downloadStorageArchive = (url: string) => {
const tempLink = document.createElement('a')
tempLink.href = url
document.body.appendChild(tempLink)
tempLink.click()
document.body.removeChild(tempLink)
}
const onSelectDownloadStorageArchive = () => {
if (!storageArchiveUrl) {
createStorageArchive({ projectRef: ref })
} else {
toast.success('Downloading storage objects')
downloadStorageArchive(storageArchiveUrl)
}
}
return (
<div className="border-t flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center px-6 py-4 bg-alternative">
<div>
<p className="text-sm">Export your data</p>
<p className="text-sm text-foreground-lighter">
Download backups for your database and storage objects
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="default" icon={<Download />} iconRight={<ChevronDown />}>
Download backups
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-60" align="end">
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={!latestBackup}
onClick={() => onSelectDownloadBackup()}
tooltip={{
content: {
side: 'right',
text: 'No backups available, please reach out via support for assistance',
},
}}
>
<Database size={16} />
Database backup (PG: {dbVersion})
</DropdownMenuItemTooltip>
<DropdownMenuItem className="gap-x-2" onClick={() => onSelectDownloadStorageArchive()}>
<Storage size={16} />
Storage objects
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
@@ -1,121 +1,23 @@
import { useParams } from 'common'
import { Database, Storage } from 'icons'
import { ChevronDown, Download, ExternalLink } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from 'ui'
import { ExternalLink } from 'lucide-react'
import { Admonition } from 'ui-patterns/admonition'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
import { DownloadBackupsSection } from './DownloadBackupsSection'
import { InlineLink } from '@/components/ui/InlineLink'
import { useBackupDownloadMutation } from '@/data/database/backup-download-mutation'
import { useProjectPauseStatusQuery } from '@/data/projects/project-pause-status-query'
import { useStorageArchiveCreateMutation } from '@/data/storage/storage-archive-create-mutation'
import { useStorageArchiveQuery } from '@/data/storage/storage-archive-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { DOCS_URL, PROJECT_STATUS } from '@/lib/constants'
import { formatRestoreWindow } from '@/lib/helpers'
export const PauseDisabledState = () => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const [toastId, setToastId] = useState<string | number>()
const [refetchInterval, setRefetchInterval] = useState<number | false>(false)
const dbVersion = project?.dbVersion?.replace('supabase-postgres-', '')
const { data: pauseStatus } = useProjectPauseStatusQuery(
{ ref },
{ enabled: project?.status === PROJECT_STATUS.INACTIVE }
)
const latestBackup = pauseStatus?.latest_downloadable_backup_id
const { data: storageArchive, isSuccess: isStorageArchiveSuccess } = useStorageArchiveQuery(
{ projectRef: ref },
{
refetchInterval,
refetchOnWindowFocus: false,
}
)
useEffect(() => {
if (!isStorageArchiveSuccess) return
if (storageArchive.fileUrl && refetchInterval !== false) {
toast.success('Downloading storage objects', { id: toastId })
setToastId(undefined)
setRefetchInterval(false)
downloadStorageArchive(storageArchive.fileUrl)
}
}, [isStorageArchiveSuccess, storageArchive, refetchInterval])
const storageArchiveUrl = storageArchive?.fileUrl
const { mutate: downloadBackup } = useBackupDownloadMutation({
onSuccess: (res) => {
const { fileUrl } = res
// Trigger browser download by create,trigger and remove tempLink
const tempLink = document.createElement('a')
tempLink.href = fileUrl
document.body.appendChild(tempLink)
tempLink.click()
document.body.removeChild(tempLink)
},
})
const { mutate: createStorageArchive } = useStorageArchiveCreateMutation({
onSuccess: () => {
const toastId = toast.loading(
'Retrieving storage archive. This may take a few minutes depending on the size of your storage objects.'
)
setToastId(toastId)
setRefetchInterval(5000)
},
})
const onSelectDownloadBackup = () => {
if (ref === undefined) return console.error('Project ref is required')
if (!latestBackup) return toast.error('No backups available for download')
const toastId = toast.loading('Fetching database backup')
downloadBackup(
{
ref,
backup: {
id: latestBackup,
},
},
{
onSuccess: () => {
toast.success('Downloading database backup', { id: toastId })
},
}
)
}
const downloadStorageArchive = (url: string) => {
const tempLink = document.createElement('a')
tempLink.href = url
document.body.appendChild(tempLink)
tempLink.click()
document.body.removeChild(tempLink)
}
const onSelectDownloadStorageArchive = () => {
if (!storageArchiveUrl) {
createStorageArchive({ projectRef: ref })
} else {
toast.success('Downloading storage objects')
downloadStorageArchive(storageArchiveUrl)
}
}
return (
<>
@@ -128,7 +30,7 @@ export const PauseDisabledState = () => {
<p className="leading-normal!">
This project has been paused for over{' '}
<span className="text-foreground">
{pauseStatus?.max_days_till_restore_disabled ?? 90} days
{formatRestoreWindow(pauseStatus?.max_days_till_restore_disabled ?? 365)}
</span>{' '}
and cannot be restored through the dashboard. However, your data remains intact and can be
downloaded as a backup.
@@ -165,46 +67,7 @@ export const PauseDisabledState = () => {
</ul>
</div>
</Admonition>
<div className="border-t flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center px-6 py-4 bg-alternative">
<div>
<p className="text-sm">Export your data</p>
<p className="text-sm text-foreground-lighter">
Download backups for your database and storage objects
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="default" icon={<Download />} iconRight={<ChevronDown />}>
Download backups
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-60" align="end">
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={!latestBackup}
onClick={() => onSelectDownloadBackup()}
tooltip={{
content: {
side: 'right',
text: 'No backups available, please reach out via support for assistance',
},
}}
>
<Database size={16} />
Database backup (PG: {dbVersion})
</DropdownMenuItemTooltip>
<DropdownMenuItem className="gap-x-2" onClick={() => onSelectDownloadStorageArchive()}>
<Storage size={16} />
Storage objects
</DropdownMenuItem>
{/* [Joshen] Once storage object download is supported, can just use the below component */}
{/* <DropdownMenuItem className="gap-x-2" onClick={() => onSelectDownloadStorageArchive()}>
<Storage size={16} />
Download storage objects
</DropdownMenuItem> */}
</DropdownMenuContent>
</DropdownMenu>
</div>
<DownloadBackupsSection />
</>
)
}
@@ -2,23 +2,14 @@ import { useParams } from 'common'
import dayjs from 'dayjs'
import { PauseCircle } from 'lucide-react'
import Link from 'next/link'
import {
Button,
Card,
CardContent,
CardFooter,
cn,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { Button, Card, CardContent, CardFooter } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { DownloadBackupsSection } from './DownloadBackupsSection'
import { PauseDisabledState } from './PauseDisabledState'
import { ResumeProjectButton } from '@/components/interfaces/Project/ResumeProjectButton'
import { AlertError } from '@/components/ui/AlertError'
import { InlineLinkClassName } from '@/components/ui/InlineLink'
import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton'
import { useProjectPauseStatusQuery } from '@/data/projects/project-pause-status-query'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
@@ -45,10 +36,11 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => {
isPending: isLoading,
} = useProjectPauseStatusQuery({ ref }, { enabled: project?.status === PROJECT_STATUS.INACTIVE })
const finalDaysRemainingBeforeRestoreDisabled =
pauseStatus?.remaining_days_till_restore_disabled ??
pauseStatus?.max_days_till_restore_disabled ??
0
// null when there is no expiry (e.g. paid plans can always restore)
const restoreExpiresAt =
pauseStatus?.remaining_days_till_restore_disabled != null
? dayjs().utc().add(pauseStatus.remaining_days_till_restore_disabled, 'day').toISOString()
: null
const isFreePlan = selectedOrganization?.plan?.id === 'free'
const isRestoreDisabled = isPauseStatusSuccess && !pauseStatus.can_restore
@@ -67,36 +59,26 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => {
isFreePlan ? (
<ul className="text-sm list-disc pl-4 space-y-2">
<li>All data, including backups and storage objects, remains safe.</li>
<li>
You can resume this project from the dashboard within{' '}
<Tooltip>
<TooltipTrigger>
<span className={cn(InlineLinkClassName, 'text-foreground')}>
{finalDaysRemainingBeforeRestoreDisabled} day
{finalDaysRemainingBeforeRestoreDisabled > 1 ? 's' : ''}
</span>{' '}
</TooltipTrigger>
<TooltipContent side="bottom" className="w-80 text-center">
Free projects cannot be restored through the dashboard if they are paused
for more than {pauseStatus.max_days_till_restore_disabled} days
</TooltipContent>
</Tooltip>{' '}
(until{' '}
<TimestampInfo
displayAs="local"
utcTimestamp={dayjs()
.utc()
.add(pauseStatus.remaining_days_till_restore_disabled ?? 0, 'day')
.toISOString()}
className="text-sm text-foreground"
labelFormat="DD MMM YYYY"
/>
).
</li>
<li>
After that, this project will not be resumable, but data will still be
available for download.
</li>
{restoreExpiresAt ? (
<>
<li>
You can resume this project from the dashboard until{' '}
<TimestampInfo
displayAs="local"
utcTimestamp={restoreExpiresAt}
className="text-sm text-foreground"
labelFormat="DD MMM YYYY"
/>
.
</li>
<li>
After that, this project will not be resumable, but data will still be
available for download.
</li>
</>
) : (
<li>You can resume this project from the dashboard.</li>
)}
<li>
{enableProBenefitWording === 'variant-a'
? 'Upgrade to Pro to prevent pauses and unlock features like branching, compute upgrades, and daily backups.'
@@ -106,22 +88,20 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => {
) : (
<>
<p className="text-sm">
Your project data is safe and available for{' '}
<span className="text-foreground">
{finalDaysRemainingBeforeRestoreDisabled} day
{finalDaysRemainingBeforeRestoreDisabled > 1 ? 's' : ''}
</span>{' '}
(until{' '}
<TimestampInfo
displayAs="local"
utcTimestamp={dayjs()
.utc()
.add(pauseStatus.remaining_days_till_restore_disabled ?? 0, 'day')
.toISOString()}
className="text-sm text-foreground"
labelFormat="DD MMM YYYY"
/>
), but inaccessible while paused.
Your project data is safe
{restoreExpiresAt ? (
<>
{' '}
and available until{' '}
<TimestampInfo
displayAs="local"
utcTimestamp={restoreExpiresAt}
className="text-sm text-foreground"
labelFormat="DD MMM YYYY"
/>
</>
) : null}
, but inaccessible while paused.
</p>
<p className="text-sm mt-2">
Once resumed, usage will be billed by compute size and hours active.
@@ -156,17 +136,20 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => {
)}
{isPauseStatusSuccess && !isRestoreDisabled && (
<CardFooter className="flex flex-wrap justify-end items-center gap-2">
<ResumeProjectButton size="tiny" variant="default" />
<>
<CardFooter className="flex flex-wrap justify-end items-center gap-2">
<ResumeProjectButton size="tiny" variant="default" />
{isFreePlan ? (
<UpgradePlanButton source="projectPausedStateRestore" />
) : (
<Button asChild variant="default">
<Link href={`/project/${ref}/settings/general`}>View project settings</Link>
</Button>
)}
</CardFooter>
{isFreePlan ? (
<UpgradePlanButton source="projectPausedStateRestore" />
) : (
<Button asChild variant="default">
<Link href={`/project/${ref}/settings/general`}>View project settings</Link>
</Button>
)}
</CardFooter>
<DownloadBackupsSection />
</>
)}
{isPauseStatusSuccess && isRestoreDisabled && <PauseDisabledState />}
@@ -52,7 +52,7 @@ export function buildPauseStatus(override: PauseStateOverride): PauseStatusRespo
can_restore: isRestorable,
last_paused_on: null,
latest_downloadable_backup_id: null,
max_days_till_restore_disabled: 90,
remaining_days_till_restore_disabled: isRestorable ? 78 : 0,
max_days_till_restore_disabled: 400,
remaining_days_till_restore_disabled: isRestorable ? 350 : 0,
}
}
+13
View File
@@ -8,6 +8,7 @@ import {
extractUrls,
formatBytes,
formatCurrency,
formatRestoreWindow,
getDatabaseMajorVersion,
getDistanceLatLonKM,
getSemanticVersion,
@@ -702,3 +703,15 @@ describe('tablesToSQL', () => {
expect(result).not.toContain('CREATE TABLE')
})
})
describe('formatRestoreWindow', () => {
it('renders windows under a year in days', () => {
expect(formatRestoreWindow(90)).toBe('90 days')
expect(formatRestoreWindow(364)).toBe('364 days')
})
it('renders windows of a year or more as 1 year', () => {
expect(formatRestoreWindow(365)).toBe('1 year')
expect(formatRestoreWindow(400)).toBe('1 year')
})
})
+3
View File
@@ -351,6 +351,9 @@ const formatSemver = (version: string) => {
return formattedSemver
}
// Windows of a year or more read better as years than day counts
export const formatRestoreWindow = (days: number) => (days >= 365 ? '1 year' : `${days} days`)
export const getSemanticVersion = (version: string) => {
if (!version) return 0