Skip to content

Commit eda9f5f

Browse files
committed
fix(sql): convert MySQL zero dates and early years
migrateScheduleDates() converted some legacy dates to wrong instants, and a migration only runs once: - MySQL in non-strict mode stores the zero date, 0000-00-00 00:00:00, for a date it cannot store. It was read as 1899-11-29, and the Knex MySQL created_at, read through UNIX_TIMESTAMP(), as 1970-01-01. - Date.UTC() reads the years 0 to 99 as 1900 to 1999, so a date in the year 0050 became 1950. The time zone offset went through it too, and read the year 0 as 1, since Intl counts it as 1 BC. - Date.UTC() moves an impossible date, such as 2026-02-31, to a later day, and MySQL can store one in non-strict mode. The zero date now converts to no date; a zero created_at reaches the error for an empty created_at, since the migrated column is required. Wall-clock times are computed with setUTCFullYear(), which keeps every year, and the offset reads the era for the years before 1 AD. An impossible date or time fails the migration with the conversion error, before the first schema change.
1 parent d4934a2 commit eda9f5f

4 files changed

Lines changed: 219 additions & 21 deletions

File tree

‎src/services/knex_queue_schema.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -469,9 +469,14 @@ export class KnexQueueSchemaService {
469469

470470
if (dialect === 'mysql') {
471471
// created_at was generated by the MySQL server in the session time zone,
472-
// so the server converts it exactly.
472+
// so the server converts it exactly. UNIX_TIMESTAMP() returns 0 for the
473+
// zero date, which is no date.
473474
if (name === 'created_at') {
474-
return this.#connection.raw('ROUND(UNIX_TIMESTAMP(??) * 1000) as ??', [name, name])
475+
return this.#connection.raw(
476+
"case when CAST(?? AS CHAR) = '0000-00-00 00:00:00' then null " +
477+
'else ROUND(UNIX_TIMESTAMP(??) * 1000) end as ??',
478+
[name, name, name]
479+
)
475480
}
476481
return this.#connection.raw('CAST(?? AS CHAR) as ??', [name, name])
477482
}

‎src/services/schedule_dates.ts‎

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ const WALL_CLOCK = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{
7878
* - Digits only: already epoch milliseconds.
7979
* - A date with `Z` or an offset: an exact instant.
8080
* - A date without a time zone: a wall-clock time in `wallClockTimeZone`.
81+
* - The MySQL zero date, which non-strict mode stores for a date it cannot
82+
* store: no date.
8183
*/
8284
export function legacyScheduleDateToEpoch(
8385
value: string | number | null | undefined,
@@ -92,11 +94,16 @@ export function legacyScheduleDateToEpoch(
9294
const wallClock = WALL_CLOCK.exec(text)
9395
if (wallClock) {
9496
const [, year, month, day, hour, minute, second, fraction = '0'] = wallClock
95-
return wallClockToEpoch(
96-
[year, month, day, hour, minute, second].map(Number) as WallClockParts,
97-
Math.round(Number(`0.${fraction}`) * 1000),
98-
wallClockTimeZone
99-
)
97+
const parts = [year, month, day, hour, minute, second].map(Number) as WallClockParts
98+
99+
if (parts.every((part) => part === 0) && Number(fraction) === 0) return null
100+
101+
// Date.UTC() moves an impossible date, such as February 31, to a later one.
102+
if (!isWallClock(parts)) {
103+
throw new Error(`Cannot convert the schedule date "${value}" to epoch milliseconds`)
104+
}
105+
106+
return wallClockToEpoch(parts, Math.round(Number(`0.${fraction}`) * 1000), wallClockTimeZone)
100107
}
101108

102109
// PostgreSQL prints offsets as `+00` or `+05:30`; normalize to `+00:00`.
@@ -155,8 +162,7 @@ type WallClockParts = [
155162
* change, which moves it forward by the size of the gap.
156163
*/
157164
function wallClockToEpoch(parts: WallClockParts, milliseconds: number, timeZone: string): number {
158-
const [year, month, day, hour, minute, second] = parts
159-
const asUtc = Date.UTC(year, month - 1, day, hour, minute, second, milliseconds)
165+
const asUtc = utcEpoch(parts, milliseconds)
160166
const DAY = 24 * 60 * 60 * 1000
161167

162168
// The offsets in effect a day before and a day after cover any DST change.
@@ -175,6 +181,7 @@ function timeZoneOffset(epoch: number, timeZone: string): number {
175181
const parts = new Intl.DateTimeFormat('en-US', {
176182
timeZone,
177183
hourCycle: 'h23',
184+
era: 'short',
178185
year: 'numeric',
179186
month: '2-digit',
180187
day: '2-digit',
@@ -184,19 +191,44 @@ function timeZoneOffset(epoch: number, timeZone: string): number {
184191
}).formatToParts(new Date(epoch))
185192

186193
const value = (type: string) => Number(parts.find((part) => part.type === type)!.value)
187-
const wallClockAsUtc = Date.UTC(
188-
value('year'),
189-
value('month') - 1,
190-
value('day'),
191-
value('hour'),
192-
value('minute'),
193-
value('second'),
194+
// Intl counts years before 1 AD from 1 BC; the year 0 is 1 BC.
195+
const era = parts.find((part) => part.type === 'era')!.value
196+
const year = era === 'BC' ? 1 - value('year') : value('year')
197+
const wallClockAsUtc = utcEpoch(
198+
[year, value('month'), value('day'), value('hour'), value('minute'), value('second')],
194199
new Date(epoch).getUTCMilliseconds()
195200
)
196201

197202
return wallClockAsUtc - epoch
198203
}
199204

205+
/**
206+
* Epoch milliseconds of a wall-clock time read as UTC. Unlike Date.UTC(), it
207+
* keeps the years 0 to 99 instead of reading them as 1900 to 1999.
208+
*/
209+
function utcEpoch(parts: WallClockParts, milliseconds: number): number {
210+
const [year, month, day, hour, minute, second] = parts
211+
const date = new Date(0)
212+
date.setUTCFullYear(year, month - 1, day)
213+
date.setUTCHours(hour, minute, second, milliseconds)
214+
return date.getTime()
215+
}
216+
217+
/** Whether the parts name a time that exists, as UTC: no month 13 or February 31. */
218+
function isWallClock(parts: WallClockParts): boolean {
219+
const date = new Date(utcEpoch(parts, 0))
220+
const [year, month, day, hour, minute, second] = parts
221+
222+
return (
223+
date.getUTCFullYear() === year &&
224+
date.getUTCMonth() === month - 1 &&
225+
date.getUTCDate() === day &&
226+
date.getUTCHours() === hour &&
227+
date.getUTCMinutes() === minute &&
228+
date.getUTCSeconds() === second
229+
)
230+
}
231+
200232
/** Time zone of the current process. */
201233
export function processTimeZone(): string {
202234
return Intl.DateTimeFormat().resolvedOptions().timeZone

‎tests/schedule_dates.spec.ts‎

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,38 @@ test.group('legacyScheduleDateToEpoch', () => {
5656
assert.isNull(legacyScheduleDateToEpoch('', 'UTC'))
5757
assert.throws(() => legacyScheduleDateToEpoch('not a date', 'UTC'), /Cannot convert/)
5858
})
59+
60+
test('reads the MySQL zero date as no date', ({ assert }) => {
61+
assert.isNull(legacyScheduleDateToEpoch('0000-00-00 00:00:00', 'Europe/Paris'))
62+
assert.isNull(legacyScheduleDateToEpoch('0000-00-00 00:00:00.000000', 'UTC'))
63+
})
64+
65+
test('keeps the years 0 to 99', ({ assert }) => {
66+
for (const year of ['0000', '0050']) {
67+
assert.equal(
68+
toIso(legacyScheduleDateToEpoch(`${year}-06-01 12:00:00`, 'UTC')),
69+
`${year}-06-01T12:00:00.000Z`
70+
)
71+
// Paris used a local mean time offset of +00:09:21 then.
72+
assert.equal(
73+
toIso(legacyScheduleDateToEpoch(`${year}-06-01 12:00:00`, 'Europe/Paris')),
74+
`${year}-06-01T11:50:39.000Z`
75+
)
76+
}
77+
})
78+
79+
test('rejects a date or time that does not exist', ({ assert }) => {
80+
for (const value of [
81+
'2026-02-31 00:00:00',
82+
'2026-00-10 00:00:00',
83+
'2026-13-01 00:00:00',
84+
'2026-01-00 00:00:00',
85+
'2026-01-01 24:00:00',
86+
'2026-01-01 00:60:00',
87+
]) {
88+
assert.throws(() => legacyScheduleDateToEpoch(value, 'UTC'), /Cannot convert/, value)
89+
}
90+
})
5991
})
6092

6193
test.group('legacyScheduleRowToEpochs', () => {
@@ -73,11 +105,13 @@ test.group('legacyScheduleRowToEpochs', () => {
73105
})
74106

75107
test('rejects a row without created_at, which the migrated table requires', ({ assert }) => {
76-
const row = { id: 'no-created-at', next_run_at: null, created_at: null }
108+
for (const createdAt of [null, '0000-00-00 00:00:00']) {
109+
const row = { id: 'no-created-at', next_run_at: null, created_at: createdAt }
77110

78-
assert.throws(
79-
() => legacyScheduleRowToEpochs(row, ['next_run_at', 'created_at'], 'UTC'),
80-
/Cannot migrate schedule "no-created-at": its created_at is empty/
81-
)
111+
assert.throws(
112+
() => legacyScheduleRowToEpochs(row, ['next_run_at', 'created_at'], 'UTC'),
113+
/Cannot migrate schedule "no-created-at": its created_at is empty/
114+
)
115+
}
82116
})
83117
})

‎tests/schedule_dates_migration.spec.ts‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,55 @@ for (const dialect of ['sqlite', 'postgres', 'mysql'] as const) {
285285
assert.equal(legacy!.nextRunAt!.toISOString(), NEXT_RUN_AT)
286286
assert.equal(legacy!.lastRunAt!.toISOString(), LAST_RUN_AT)
287287
})
288+
289+
test('reads the MySQL zero date as no date', async ({ assert }) => {
290+
// Non-strict mode stores the zero date for a date it cannot store.
291+
await connection.transaction(async (trx) => {
292+
const [[current]] = await trx.raw('select @@session.sql_mode as sql_mode')
293+
await trx.raw("set session sql_mode = ''")
294+
await trx(TABLE).insert({
295+
id: 'legacy',
296+
name: 'LegacyJob',
297+
payload: '{}',
298+
every_ms: 60_000,
299+
next_run_at: '0000-00-00 00:00:00',
300+
last_run_at: 'not a date',
301+
})
302+
await trx.raw('set session sql_mode = ?', [current.sql_mode])
303+
})
304+
305+
await new KnexQueueSchemaService(connection).migrateScheduleDates(TABLE, {
306+
timezone: WRITER_TIME_ZONE,
307+
})
308+
309+
const legacy = await adapter().getSchedule('legacy')
310+
assert.isNull(legacy!.nextRunAt)
311+
assert.isNull(legacy!.lastRunAt)
312+
})
313+
314+
test('rejects a zero created_at before changing the table', async ({ assert }) => {
315+
await connection.transaction(async (trx) => {
316+
const [[current]] = await trx.raw('select @@session.sql_mode as sql_mode')
317+
await trx.raw("set session sql_mode = ''")
318+
await trx(TABLE).insert({
319+
id: 'legacy',
320+
name: 'LegacyJob',
321+
payload: '{}',
322+
every_ms: 60_000,
323+
created_at: '0000-00-00 00:00:00',
324+
})
325+
await trx.raw('set session sql_mode = ?', [current.sql_mode])
326+
})
327+
328+
await assert.rejects(
329+
() =>
330+
new KnexQueueSchemaService(connection).migrateScheduleDates(TABLE, {
331+
timezone: WRITER_TIME_ZONE,
332+
}),
333+
/Cannot migrate schedule "legacy": its created_at is empty/
334+
)
335+
assert.match((await connection(TABLE).columnInfo('created_at')).type, /timestamp/i)
336+
})
288337
}
289338

290339
if (dialect === 'postgres') {
@@ -590,6 +639,84 @@ for (const dialect of ['sqlite', 'postgres', 'mysql'] as const) {
590639

591640
await assertMigratedSchedules(assert, adapter(), { exact: CREATED_AT })
592641
})
642+
643+
test('reads the MySQL zero date as no date', async ({ assert }) => {
644+
// Non-strict mode stores the zero date for a date it cannot store.
645+
await connection.connection().execute(async (db) => {
646+
const current = await sql<{
647+
sqlMode: string
648+
}>`select @@session.sql_mode as ${sql.ref('sqlMode')}`.execute(db)
649+
await sql`set session sql_mode = ''`.execute(db)
650+
await db
651+
.insertInto(TABLE)
652+
.values({
653+
id: 'legacy',
654+
name: 'LegacyJob',
655+
payload: '{}',
656+
every_ms: 60_000,
657+
next_run_at: '0000-00-00 00:00:00',
658+
last_run_at: 'not a date',
659+
})
660+
.execute()
661+
await sql`set session sql_mode = ${current.rows[0].sqlMode}`.execute(db)
662+
})
663+
664+
await new KyselyQueueSchemaService(connection, { dialect }).migrateScheduleDates(TABLE, {
665+
timezone: WRITER_TIME_ZONE,
666+
})
667+
668+
const legacy = await adapter().getSchedule('legacy')
669+
assert.isNull(legacy!.nextRunAt)
670+
assert.isNull(legacy!.lastRunAt)
671+
})
672+
673+
test('rejects a zero created_at before changing the table', async ({ assert }) => {
674+
await connection.connection().execute(async (db) => {
675+
const current = await sql<{
676+
sqlMode: string
677+
}>`select @@session.sql_mode as ${sql.ref('sqlMode')}`.execute(db)
678+
await sql`set session sql_mode = ''`.execute(db)
679+
await db
680+
.insertInto(TABLE)
681+
.values({
682+
id: 'legacy',
683+
name: 'LegacyJob',
684+
payload: '{}',
685+
every_ms: 60_000,
686+
created_at: '0000-00-00 00:00:00',
687+
})
688+
.execute()
689+
await sql`set session sql_mode = ${current.rows[0].sqlMode}`.execute(db)
690+
})
691+
692+
await assert.rejects(
693+
() =>
694+
new KyselyQueueSchemaService(connection, { dialect }).migrateScheduleDates(TABLE, {
695+
timezone: WRITER_TIME_ZONE,
696+
}),
697+
/Cannot migrate schedule "legacy": its created_at is empty/
698+
)
699+
assert.match((await columnTypes()).get('created_at')!, /timestamp/i)
700+
})
701+
}
702+
703+
if (dialect === 'sqlite') {
704+
test('keeps a date in the year 0', async ({ assert }) => {
705+
// SQLite stores the text it is given; the migration reads it as UTC.
706+
await insertLegacySchedule()
707+
await connection
708+
.updateTable(TABLE)
709+
.set({ next_run_at: '0000-06-01 12:00:00' })
710+
.where('id', '=', 'legacy')
711+
.execute()
712+
713+
await new KyselyQueueSchemaService(connection, { dialect }).migrateScheduleDates(TABLE, {
714+
timezone: WRITER_TIME_ZONE,
715+
})
716+
717+
const legacy = await adapter().getSchedule('legacy')
718+
assert.equal(legacy!.nextRunAt!.toISOString(), '0000-06-01T12:00:00.000Z')
719+
})
593720
}
594721

595722
if (dialect === 'postgres') {

0 commit comments

Comments
 (0)