diff --git a/apps/docs/content/guides/platform/free-project-pausing.mdx b/apps/docs/content/guides/platform/free-project-pausing.mdx index 9e6b2fc6bbf..65db07fdac4 100644 --- a/apps/docs/content/guides/platform/free-project-pausing.mdx +++ b/apps/docs/content/guides/platform/free-project-pausing.mdx @@ -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 diff --git a/apps/docs/content/guides/platform/upgrading.mdx b/apps/docs/content/guides/platform/upgrading.mdx index d78437d7917..cc263f84b46 100644 --- a/apps/docs/content/guides/platform/upgrading.mdx +++ b/apps/docs/content/guides/platform/upgrading.mdx @@ -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). Project Paused: 90 Days Remaining -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). Project Paused: Paid Tier Restore { Pause {entityLabel}? 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. diff --git a/apps/studio/components/layouts/ProjectLayout/PausedState/DownloadBackupsSection.tsx b/apps/studio/components/layouts/ProjectLayout/PausedState/DownloadBackupsSection.tsx new file mode 100644 index 00000000000..12895300108 --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/PausedState/DownloadBackupsSection.tsx @@ -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() + const [refetchInterval, setRefetchInterval] = useState(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 ( +
+
+

Export your data

+

+ Download backups for your database and storage objects +

+
+ + + + + + onSelectDownloadBackup()} + tooltip={{ + content: { + side: 'right', + text: 'No backups available, please reach out via support for assistance', + }, + }} + > + + Database backup (PG: {dbVersion}) + + onSelectDownloadStorageArchive()}> + + Storage objects + + + +
+ ) +} diff --git a/apps/studio/components/layouts/ProjectLayout/PausedState/PauseDisabledState.tsx b/apps/studio/components/layouts/ProjectLayout/PausedState/PauseDisabledState.tsx index 25dcf86ec2b..dc9a1e9b996 100644 --- a/apps/studio/components/layouts/ProjectLayout/PausedState/PauseDisabledState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PausedState/PauseDisabledState.tsx @@ -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() - const [refetchInterval, setRefetchInterval] = useState(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 = () => {

This project has been paused for over{' '} - {pauseStatus?.max_days_till_restore_disabled ?? 90} days + {formatRestoreWindow(pauseStatus?.max_days_till_restore_disabled ?? 365)} {' '} 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 = () => { -

-
-

Export your data

-

- Download backups for your database and storage objects -

-
- - - - - - onSelectDownloadBackup()} - tooltip={{ - content: { - side: 'right', - text: 'No backups available, please reach out via support for assistance', - }, - }} - > - - Database backup (PG: {dbVersion}) - - onSelectDownloadStorageArchive()}> - - Storage objects - - {/* [Joshen] Once storage object download is supported, can just use the below component */} - {/* onSelectDownloadStorageArchive()}> - - Download storage objects - */} - - -
+ ) } diff --git a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx index 3a0ea112408..cda99e5774f 100644 --- a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx @@ -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 ? (
  • All data, including backups and storage objects, remains safe.
  • -
  • - You can resume this project from the dashboard within{' '} - - - - {finalDaysRemainingBeforeRestoreDisabled} day - {finalDaysRemainingBeforeRestoreDisabled > 1 ? 's' : ''} - {' '} - - - Free projects cannot be restored through the dashboard if they are paused - for more than {pauseStatus.max_days_till_restore_disabled} days - - {' '} - (until{' '} - - ). -
  • -
  • - After that, this project will not be resumable, but data will still be - available for download. -
  • + {restoreExpiresAt ? ( + <> +
  • + You can resume this project from the dashboard until{' '} + + . +
  • +
  • + After that, this project will not be resumable, but data will still be + available for download. +
  • + + ) : ( +
  • You can resume this project from the dashboard.
  • + )}
  • {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) => { ) : ( <>

    - Your project data is safe and available for{' '} - - {finalDaysRemainingBeforeRestoreDisabled} day - {finalDaysRemainingBeforeRestoreDisabled > 1 ? 's' : ''} - {' '} - (until{' '} - - ), but inaccessible while paused. + Your project data is safe + {restoreExpiresAt ? ( + <> + {' '} + and available until{' '} + + + ) : null} + , but inaccessible while paused.

    Once resumed, usage will be billed by compute size and hours active. @@ -156,17 +136,20 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { )} {isPauseStatusSuccess && !isRestoreDisabled && ( - - + <> + + - {isFreePlan ? ( - - ) : ( - - )} - + {isFreePlan ? ( + + ) : ( + + )} + + + )} {isPauseStatusSuccess && isRestoreDisabled && } diff --git a/apps/studio/data/projects/project-pause-status-override.ts b/apps/studio/data/projects/project-pause-status-override.ts index 0277594c14f..9e617afe208 100644 --- a/apps/studio/data/projects/project-pause-status-override.ts +++ b/apps/studio/data/projects/project-pause-status-override.ts @@ -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, } } diff --git a/apps/studio/lib/helpers.test.ts b/apps/studio/lib/helpers.test.ts index b5bfd570443..2f77b957d97 100644 --- a/apps/studio/lib/helpers.test.ts +++ b/apps/studio/lib/helpers.test.ts @@ -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') + }) +}) diff --git a/apps/studio/lib/helpers.ts b/apps/studio/lib/helpers.ts index 558a5dbf46f..2206be48975 100644 --- a/apps/studio/lib/helpers.ts +++ b/apps/studio/lib/helpers.ts @@ -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