Skip to content

Commit d4934a2

Browse files
committed
fix(sql): read PostgreSQL dates in ISO and UTC
migrateScheduleDates() reads the legacy dates as text, and on PostgreSQL that text depends on the session. A DateStyle other than ISO, such as 'SQL, DMY', printed "25/10/2026 02:30:00", which the conversion rejected. The TimeZone sets the offset printed for timestamptz values (Knex tables): before standard time it has seconds, such as "1900-01-01 00:09:21+00:09:21" in Europe/Paris, which the conversion rejected too. Both failures rolled back the migration, which could not run on such a database. The migration now sets DateStyle to ISO and TimeZone to UTC for the read, as transaction-local settings, then restores the previous values so the rest of an outer transaction, such as the Kysely Migrator or an AdonisJS migration, keeps them. The restore only follows a successful read: a failed statement aborts the transaction, so a restore would fail and hide the error, and the rollback undoes the settings anyway. The converted values are unchanged: a timestamptz is an instant whatever the TimeZone, and a timestamp has no offset.
1 parent 56b67e3 commit d4934a2

3 files changed

Lines changed: 123 additions & 8 deletions

File tree

‎src/services/knex_queue_schema.ts‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ export class KnexQueueSchemaService {
224224
// Convert every value before the first schema change, so an unreadable
225225
// value fails the migration without leaving the table half migrated.
226226
const rows = state.legacy.length
227-
? await this.#withDatabaseTimeZone(trx, dialect, options.databaseTimeZone, async () => {
227+
? await this.#withLegacyDateSession(trx, dialect, options.databaseTimeZone, async () => {
228228
return (await trx(tableName).select(
229229
'id',
230230
...state.legacy.map((name) => this.#legacyDateAsText(dialect, name))
@@ -321,15 +321,42 @@ export class KnexQueueSchemaService {
321321
}
322322

323323
/**
324-
* Run `callback` with the MySQL session time zone set to `timeZone`, then
325-
* restore it. MySQL converts stored dates through the session time zone.
324+
* Run `callback`, which reads the legacy dates as text, with the session
325+
* settings that text depends on, then restore them.
326+
*
327+
* - PostgreSQL: the DateStyle sets the format, and the TimeZone the offset
328+
* of timestamptz values, which has seconds before standard time (Paris
329+
* was +00:09:21 in 1900). ISO and UTC give text the conversion reads.
330+
* - MySQL: stored dates are converted through the session time zone, set
331+
* to `timeZone` when given.
326332
*/
327-
async #withDatabaseTimeZone<T>(
333+
async #withLegacyDateSession<T>(
328334
trx: Knex.Transaction,
329335
dialect: 'pg' | 'mysql' | 'sqlite',
330336
timeZone: string | undefined,
331337
callback: () => Promise<T>
332338
): Promise<T> {
339+
if (dialect === 'pg') {
340+
const {
341+
rows: [current],
342+
} = await trx.raw(
343+
"select current_setting('DateStyle') as date_style, current_setting('TimeZone') as time_zone"
344+
)
345+
await trx.raw(
346+
"select set_config('DateStyle', 'ISO', true), set_config('TimeZone', 'UTC', true)"
347+
)
348+
349+
// Only after a success: a failed statement aborts the transaction, so a
350+
// restore would fail and hide the error, and the rollback undoes these
351+
// transaction-local settings anyway.
352+
const result = await callback()
353+
await trx.raw("select set_config('DateStyle', ?, true), set_config('TimeZone', ?, true)", [
354+
current.date_style,
355+
current.time_zone,
356+
])
357+
return result
358+
}
359+
333360
if (dialect !== 'mysql' || timeZone === undefined) return callback()
334361

335362
const [[current]] = await trx.raw('select @@session.time_zone as time_zone')

‎src/services/kysely_queue_schema.ts‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ export class KyselyQueueSchemaService<DB> {
216216
// Convert every value before the first schema change, so an unreadable
217217
// value fails the migration without leaving the table half migrated.
218218
const rows = state.legacy.length
219-
? await this.#withDatabaseTimeZone(trx, options.databaseTimeZone, async () => {
219+
? await this.#withLegacyDateSession(trx, options.databaseTimeZone, async () => {
220220
// The query builder applies withSchema(), unlike raw SQL table references.
221221
return (await (trx as unknown as Kysely<Record<string, Record<string, unknown>>>)
222222
.selectFrom(tableName)
@@ -364,14 +364,40 @@ export class KyselyQueueSchemaService<DB> {
364364
}
365365

366366
/**
367-
* Run `callback` with the MySQL session time zone set to `timeZone`, then
368-
* restore it. MySQL converts stored dates through the session time zone.
367+
* Run `callback`, which reads the legacy dates as text, with the session
368+
* settings that text depends on, then restore them.
369+
*
370+
* - PostgreSQL: the DateStyle sets the format, and the TimeZone the offset
371+
* of timestamptz values, which has seconds before standard time (Paris
372+
* was +00:09:21 in 1900). ISO and UTC give text the conversion reads.
373+
* - MySQL: stored dates are converted through the session time zone, set
374+
* to `timeZone` when given.
369375
*/
370-
async #withDatabaseTimeZone<T>(
376+
async #withLegacyDateSession<T>(
371377
trx: Transaction<DB>,
372378
timeZone: string | undefined,
373379
callback: () => Promise<T>
374380
): Promise<T> {
381+
if (this.#dialect === 'postgres') {
382+
const current = await sql<{ dateStyle: string; timeZone: string }>`
383+
select current_setting('DateStyle') as ${sql.ref('dateStyle')},
384+
current_setting('TimeZone') as ${sql.ref('timeZone')}
385+
`.execute(trx)
386+
await sql`select set_config('DateStyle', 'ISO', true), set_config('TimeZone', 'UTC', true)`.execute(
387+
trx
388+
)
389+
390+
// Only after a success: a failed statement aborts the transaction, so a
391+
// restore would fail and hide the error, and the rollback undoes these
392+
// transaction-local settings anyway.
393+
const result = await callback()
394+
const { dateStyle, timeZone: sessionTimeZone } = current.rows[0]
395+
await sql`select set_config('DateStyle', ${dateStyle}, true), set_config('TimeZone', ${sessionTimeZone}, true)`.execute(
396+
trx
397+
)
398+
return result
399+
}
400+
375401
if (this.#dialect !== 'mysql' || timeZone === undefined) return callback()
376402

377403
const current = await sql<{

‎tests/schedule_dates_migration.spec.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,39 @@ for (const dialect of ['sqlite', 'postgres', 'mysql'] as const) {
287287
})
288288
}
289289

290+
if (dialect === 'postgres') {
291+
test('reads dates whatever the DateStyle and TimeZone of the session', async ({
292+
assert,
293+
cleanup,
294+
}) => {
295+
const sessionConnection = Knex({
296+
client: 'pg',
297+
connection: { ...pgConfig(), options: '-c DateStyle=SQL,DMY -c TimeZone=Europe/Paris' },
298+
})
299+
cleanup(() => sessionConnection.destroy())
300+
const createdAt = await insertLegacySchedule()
301+
// Paris used a local mean time offset of +00:09:21 in 1900.
302+
await connection(TABLE).update({ from_date: new Date('1900-01-01T00:00:00.000Z') })
303+
304+
// Inside an outer transaction, as in an AdonisJS migration, which keeps
305+
// the settings the migration changes until it ends.
306+
const settings = await sessionConnection.transaction(async (trx) => {
307+
await new KnexQueueSchemaService(trx).migrateScheduleDates(TABLE, {
308+
timezone: WRITER_TIME_ZONE,
309+
})
310+
const { rows } = await trx.raw(
311+
"select current_setting('DateStyle') as date_style, current_setting('TimeZone') as time_zone"
312+
)
313+
return rows[0]
314+
})
315+
assert.deepEqual(settings, { date_style: 'SQL, DMY', time_zone: 'Europe/Paris' })
316+
const legacy = await adapter().getSchedule('legacy')
317+
assert.equal(legacy!.from!.toISOString(), '1900-01-01T00:00:00.000Z')
318+
await connection(TABLE).update({ from_date: null })
319+
await assertMigratedSchedules(assert, adapter(), createdAt)
320+
})
321+
}
322+
290323
if (dialect === 'postgres') {
291324
test('migrates a schema-qualified table', async ({ assert, cleanup }) => {
292325
await connection.raw('create schema if not exists review_tenant')
@@ -580,6 +613,35 @@ for (const dialect of ['sqlite', 'postgres', 'mysql'] as const) {
580613
})
581614
}
582615

616+
if (dialect === 'postgres') {
617+
test('reads dates whatever the DateStyle of the session', async ({ assert, cleanup }) => {
618+
const sessionConnection = new Kysely<any>({
619+
dialect: new PostgresDialect({
620+
pool: new Pool({
621+
...pgConfig(),
622+
options: '-c DateStyle=SQL,DMY -c TimeZone=Europe/Paris',
623+
}),
624+
}),
625+
})
626+
cleanup(() => sessionConnection.destroy())
627+
const createdAt = await insertLegacySchedule()
628+
629+
// Inside an outer transaction, as in the Kysely Migrator, which keeps
630+
// the settings the migration changes until it ends.
631+
const settings = await sessionConnection.transaction().execute(async (trx) => {
632+
await new KyselyQueueSchemaService(trx, { dialect }).migrateScheduleDates(TABLE, {
633+
timezone: WRITER_TIME_ZONE,
634+
})
635+
const { rows } = await sql<{ date_style: string; time_zone: string }>`
636+
select current_setting('DateStyle') as date_style, current_setting('TimeZone') as time_zone
637+
`.execute(trx)
638+
return rows[0]
639+
})
640+
assert.deepEqual(settings, { date_style: 'SQL, DMY', time_zone: 'Europe/Paris' })
641+
await assertMigratedSchedules(assert, adapter(), createdAt)
642+
})
643+
}
644+
583645
if (dialect === 'postgres') {
584646
test('migrates a schema-qualified table', async ({ assert, cleanup }) => {
585647
await sql`create schema if not exists review_tenant`.execute(connection)

0 commit comments

Comments
 (0)