diff --git a/lib/modules/datasource/metadata.ts b/lib/modules/datasource/metadata.ts index 8f6fd5fc56f5cf6724c32c56a58e8080cbd80665..bdfbe258b026454a1e6024c848f7776196848a87 100644 --- a/lib/modules/datasource/metadata.ts +++ b/lib/modules/datasource/metadata.ts @@ -64,6 +64,9 @@ function massageGitAtUrl(url: string): string { return massagedUrl; } +/** + * @deprecated Use `asTimestamp` instead + */ export function normalizeDate(input: any): string | null { if ( typeof input === 'number' && diff --git a/lib/util/timestamp.spec.ts b/lib/util/timestamp.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..c084c062f3025ededb6e188ec499ec4d014e0e53 --- /dev/null +++ b/lib/util/timestamp.spec.ts @@ -0,0 +1,39 @@ +import { TimestampSchema, asTimestamp } from './timestamp'; + +describe('util/timestamp', () => { + describe('asTimestamp', () => { + test.each` + input | expected + ${new Date('2021-01-01T00:00:00.000Z')} | ${'2021-01-01T00:00:00.000Z'} + ${new Date('2021-01-01T00:00:00.000-03:00')} | ${'2021-01-01T03:00:00.000Z'} + ${new Date('1999-01-01T00:00:00.000Z')} | ${null} + ${1609459200000} | ${'2021-01-01T00:00:00.000Z'} + ${1609459200} | ${'2021-01-01T00:00:00.000Z'} + ${-1} | ${null} + ${0} | ${null} + ${123} | ${null} + ${NaN} | ${null} + ${'2021-01-01T00:00:00.000Z'} | ${'2021-01-01T00:00:00.000Z'} + ${'2021-01-01'} | ${'2021-01-01T00:00:00.000Z'} + ${'20210101000000'} | ${'2021-01-01T00:00:00.000Z'} + ${'20211231235959'} | ${'2021-12-31T23:59:59.000Z'} + ${'Jan 1, 2021'} | ${'2021-01-01T00:00:00.000Z'} + ${'2021/01/01'} | ${'2021-01-01T00:00:00.000Z'} + ${'2021-01-02T00:00:00+05:30'} | ${'2021-01-01T18:30:00.000Z'} + ${'2010-05-20T22:43:19-07:00'} | ${'2010-05-21T05:43:19.000Z'} + ${'2021-10-11 07:47:24 -0700'} | ${'2021-10-11T14:47:24.000Z'} + ${'Wed, 21 Oct 2015 07:28:00 GMT'} | ${'2015-10-21T07:28:00.000Z'} + ${null} | ${null} + ${undefined} | ${null} + ${{}} | ${null} + ${[]} | ${null} + ${'invalid date'} | ${null} + ${'202x0101000000'} | ${null} + `('$input -> $expected', ({ input, expected }) => { + expect(asTimestamp(input)).toBe(expected); + expect(TimestampSchema.nullable().catch(null).parse(input)).toBe( + expected, + ); + }); + }); +}); diff --git a/lib/util/timestamp.ts b/lib/util/timestamp.ts new file mode 100644 index 0000000000000000000000000000000000000000..b40fd93ead2d2a7cf346f8ddbd78d09682159d04 --- /dev/null +++ b/lib/util/timestamp.ts @@ -0,0 +1,92 @@ +import { DateTime } from 'luxon'; +import { z } from 'zod'; + +export type Timestamp = string & { __timestamp: never }; + +const timezoneOffset = new Date().getTimezoneOffset() * 60000; + +const millenium = 946684800000; // 2000-01-01T00:00:00.000Z +const tomorrowOffset = 86400000; // 24 * 60 * 60 * 1000; + +function isValid(date: DateTime): boolean { + if (!date.isValid) { + return false; + } + const tomorrow = DateTime.now().toMillis() + tomorrowOffset; // 24 * 60 * 60 * 1000; + const ts = date.toMillis(); + return ts > millenium && ts < tomorrow; +} + +export function asTimestamp(input: unknown): Timestamp | null { + if (input instanceof Date) { + const date = DateTime.fromJSDate(input, { zone: 'UTC' }); + if (isValid(date)) { + return date.toISO() as Timestamp; + } + + return null; + } + + if (typeof input === 'number') { + const millisDate = DateTime.fromMillis(input, { zone: 'UTC' }); + if (isValid(millisDate)) { + return millisDate.toISO() as Timestamp; + } + + const secondsDate = DateTime.fromSeconds(input, { zone: 'UTC' }); + if (isValid(secondsDate)) { + return secondsDate.toISO() as Timestamp; + } + + return null; + } + + if (typeof input === 'string') { + const isoDate = DateTime.fromISO(input, { zone: 'UTC' }); + if (isValid(isoDate)) { + return isoDate.toISO() as Timestamp; + } + + const httpDate = DateTime.fromHTTP(input, { zone: 'UTC' }); + if (isValid(httpDate)) { + return httpDate.toISO() as Timestamp; + } + + const sqlDate = DateTime.fromSQL(input, { zone: 'UTC' }); + if (isValid(sqlDate)) { + return sqlDate.toISO() as Timestamp; + } + + const numberLikeDate = DateTime.fromFormat(input, 'yyyyMMddHHmmss', { + zone: 'UTC', + }); + if (isValid(numberLikeDate)) { + return numberLikeDate.toISO() as Timestamp; + } + + const fallbackDate = DateTime.fromMillis( + Date.parse(input) - timezoneOffset, + { zone: 'UTC' }, + ); + if (isValid(fallbackDate)) { + return fallbackDate.toISO() as Timestamp; + } + + return null; + } + + return null; +} + +export const TimestampSchema = z.unknown().transform((input, ctx) => { + const timestamp = asTimestamp(input); + if (!timestamp) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid timestamp', + }); + return z.NEVER; + } + + return timestamp; +});