fix: added logic to check and parse valid day.js (#40960)

* wip: added logic for migration checking

* updated null check

* updated to return undefined

* updated use memo check

* updated migration text

* updated text based on migrations data

* re-added tabbed deleted code

* Updated insertedAt date logic

* Fit missing gap + add tooltip

* Niot

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Ali WaseemandJoshen Lim authored and GitHub committed 2025-12-02 13:18:33 +08:00
1 parent 47d99bb1a6
commit 74b13e2cef
4 files changed
+173 -27

No files matched your search

@@ -1,17 +1,19 @@
import dayjs from 'dayjs'
import { Search } from 'lucide-react'
import { useState } from 'react'
import { SupportCategories } from '@supabase/shared-types/out/constants'
import { SupportLink } from 'components/interfaces/Support/SupportLink'
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
import { InlineLink } from 'components/ui/InlineLink'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { DatabaseMigration, useMigrationsQuery } from 'data/database/migrations-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { Search } from 'lucide-react'
import { DOCS_URL } from 'lib/constants'
import { parseMigrationVersion } from 'lib/migration-utils'
import {
Button,
Card,
Input,
cn,
SidePanel,
Table,
TableBody,
@@ -19,8 +21,12 @@ import {
TableHead,
TableHeader,
TableRow,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
import { MigrationsEmptyState } from './MigrationsEmptyState'
const Migrations = () => {
@@ -28,14 +34,20 @@ const Migrations = () => {
const [selectedMigration, setSelectedMigration] = useState<DatabaseMigration>()
const { data: project } = useSelectedProjectQuery()
const { data, isLoading, isSuccess, isError, error } = useMigrationsQuery({
const {
data = [],
isLoading,
isSuccess,
isError,
error,
} = useMigrationsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const migrations =
search.length === 0
? data ?? []
: data?.filter(
? data
: data.filter(
(migration) => migration.version.includes(search) || migration.name?.includes(search)
) ?? []
@@ -82,7 +94,7 @@ const Migrations = () => {
{data.length <= 0 && <MigrationsEmptyState />}
{data.length > 0 && (
<>
<div className="flex flex-col gap-y-4">
<Input
size="tiny"
placeholder="Search for a migration"
@@ -98,32 +110,46 @@ const Migrations = () => {
<TableHead key="version" style={{ width: '180px' }}>
Version
</TableHead>
<TableHead key="version">Name</TableHead>
<TableHead key="version">Inserted at (UTC)</TableHead>
<TableHead key="buttons"></TableHead>
<TableHead key="name">Name</TableHead>
<TableHead key="insertedAt">Inserted at (UTC)</TableHead>
<TableHead key="buttons" />
</TableRow>
</TableHeader>
<TableBody>
{migrations.length > 0 ? (
migrations.map((migration) => {
// [Joshen] LEFT OFF HERE
const insertedAt = dayjs(migration.version, 'YYYYMMDDHHmmss').format(
'DD MMM YYYY, HH:mm:ss'
)
const versionDayjs = parseMigrationVersion(migration.version)
const insertedAt = versionDayjs
? versionDayjs.format('DD MMM YYYY, HH:mm:ss')
: undefined
return (
<TableRow key={migration.version}>
<TableCell>{migration.version}</TableCell>
<TableCell
className={
(migration?.name ?? '').length === 0
? '!text-foreground-lighter'
: ''
}
className={cn(
(migration?.name ?? '').length === 0 && '!text-foreground-lighter'
)}
>
{migration?.name ?? 'Name not available'}
</TableCell>
<TableCell>{insertedAt}</TableCell>
<TableCell className={cn(!insertedAt && 'text-foreground-lighter')}>
<Tooltip>
<TooltipTrigger>{insertedAt ?? 'Unknown'}</TooltipTrigger>
{!insertedAt && (
<TooltipContent side="right" className="w-64 text-center">
This migration was not generated via the{' '}
<InlineLink
href={`${DOCS_URL}/guides/deployment/database-migrations`}
>
Supabase CLI
</InlineLink>{' '}
and hence we're unable to parse when this migration was
inserted at.
</TooltipContent>
)}
</Tooltip>
</TableCell>
<TableCell align="right">
<Button
type="default"
@@ -148,7 +174,7 @@ const Migrations = () => {
</TableBody>
</Table>
</Card>
</>
</div>
)}
</div>
)}
@@ -6,8 +6,9 @@ import { useParams } from 'common'
import { SingleStat } from 'components/ui/SingleStat'
import { useBranchesQuery } from 'data/branches/branches-query'
import { useBackupsQuery } from 'data/database/backups-query'
import { useMigrationsQuery } from 'data/database/migrations-query'
import { DatabaseMigration, useMigrationsQuery } from 'data/database/migrations-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { parseMigrationVersion } from 'lib/migration-utils'
import { cn, Skeleton } from 'ui'
import { TimestampInfo } from 'ui-patterns'
import { ServiceStatus } from './ServiceStatus'
@@ -40,7 +41,10 @@ export const ActivityStats = () => {
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const latestMigration = useMemo(() => (migrationsData ?? [])[0], [migrationsData])
const latestMigration = useMemo<DatabaseMigration | undefined>(
() => (migrationsData ?? [])[0],
[migrationsData]
)
const { data: backupsData, isLoading: isLoadingBackups } = useBackupsQuery({
projectRef: project?.ref,
@@ -53,6 +57,21 @@ export const ActivityStats = () => {
.sort((a, b) => new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf())[0]
}, [backupsData])
const [versionLabel, versionTimestamp] = useMemo(() => {
const version = latestMigration?.version
const versionDayjs = parseMigrationVersion(version)
if (versionDayjs) {
return [versionDayjs.fromNow(), versionDayjs.toISOString()]
}
return [undefined, undefined]
}, [latestMigration])
const hasValidVersion = versionLabel && versionTimestamp
const versionLabelText = migrationsData && migrationsData.length > 0 ? 'Unknown' : 'No migrations'
return (
<div className="@container">
<div className="grid grid-cols-1 @md:grid-cols-2 gap-2 @md:gap-6 flex-wrap">
@@ -69,14 +88,14 @@ export const ActivityStats = () => {
value={
isLoadingMigrations ? (
<Skeleton className="h-6 w-24" />
) : latestMigration ? (
) : hasValidVersion ? (
<TimestampInfo
className="text-base"
label={dayjs(latestMigration.version, 'YYYYMMDDHHmmss').fromNow()}
utcTimestamp={dayjs(latestMigration.version, 'YYYYMMDDHHmmss').toISOString()}
label={versionLabel}
utcTimestamp={versionTimestamp}
/>
) : (
<p className="text-foreground-lighter">No migrations</p>
<p className="text-foreground-lighter">{versionLabelText}</p>
)
}
/>
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { parseMigrationVersion } from './migration-utils'
import dayjs from 'dayjs'
describe('parseMigrationVersion', () => {
it('should parse valid migration version in YYYYMMDDHHmmss format', () => {
const result = parseMigrationVersion('20231128095400')
expect(result).not.toBeNull()
expect(result?.isValid()).toBe(true)
expect(result?.year()).toBe(2023)
expect(result?.month()).toBe(10)
expect(result?.date()).toBe(28)
expect(result?.hour()).toBe(9)
expect(result?.minute()).toBe(54)
expect(result?.second()).toBe(0)
})
it('should return undefined for invalid version format like "001"', () => {
const result = parseMigrationVersion('001')
expect(result).toBeUndefined()
})
it('should return undefined for invalid version format like "002"', () => {
const result = parseMigrationVersion('002')
expect(result).toBeUndefined()
})
it('should return undefined for invalid version format like "003"', () => {
const result = parseMigrationVersion('003')
expect(result).toBeUndefined()
})
it('should return undefined for empty string', () => {
const result = parseMigrationVersion('')
expect(result).toBeUndefined()
})
it('should return undefined for random string', () => {
const result = parseMigrationVersion('not-a-date')
expect(result).toBeUndefined()
})
it('should return undefined for partial date format', () => {
const result = parseMigrationVersion('20231128')
expect(result).toBeUndefined()
})
it('should handle edge case date values that dayjs can parse', () => {
// dayjs is lenient and will wrap invalid values to valid dates
// This is acceptable for our use case - the main goal is to reject
// non-date formats like "001", "002", etc.
const result = parseMigrationVersion('20231399000000')
expect(result).not.toBeNull()
expect(result?.isValid()).toBe(true)
})
it('should allow chaining dayjs methods when valid', () => {
const result = parseMigrationVersion('20231128095400')
expect(result?.fromNow()).toBeDefined()
expect(result?.toISOString()).toBeDefined()
expect(result?.format('DD MMM YYYY')).toBe('28 Nov 2023')
})
})
+29
View File
@@ -0,0 +1,29 @@
import dayjs, { Dayjs } from 'dayjs'
/**
* Safely parses a migration version string as a date.
* Migration versions are typically in the format YYYYMMDDHHmmss (e.g., "20231128095400").
* However, some projects may have custom version formats (e.g., "001", "002") that cannot be parsed as dates.
*
* @param version - Migration version string
* @returns Dayjs object if the version is a valid datetime, null otherwise
*
* @example
* const parsed = parseMigrationVersion('20231128095400')
* if (parsed) {
* console.log(parsed.fromNow()) // "2 hours ago"
* console.log(parsed.format('DD MMM YYYY')) // "28 Nov 2023"
* }
*
* @example
* const invalid = parseMigrationVersion('001') // returns null
*/
export function parseMigrationVersion(version: string | null | undefined): Dayjs | undefined {
if (!version) return undefined
// Must contain only digits
if (!/^\d{14}$/.test(version)) return undefined
const parsed = dayjs(version, 'YYYYMMDDHHmmss', true)
return parsed.isValid() ? parsed : undefined
}