diff --git a/ghost/core/server/data/migrations/versions/6.73/2026-10-21-21-22-05-convert-automation-dates-to-sqlite-text.js b/ghost/core/server/data/migrations/versions/6.73/2026-10-21-21-22-05-convert-automation-dates-to-sqlite-text.js new file mode 100644 index 000000000000..80a134c60683 --- /dev/null +++ b/ghost/core/server/data/migrations/versions/6.73/2026-10-21-21-22-05-convert-automation-dates-to-sqlite-text.js @@ -0,0 +1,24 @@ +const DatabaseInfo = require('@tryghost/database-info'); +const logging = require('@tryghost/logging'); +const { createIrreversibleMigration } = require('../../utils'); + +const convertDateColumn = (knex, table, column) => + knex.raw( + `UPDATE ?? + SET ?? = strftime('%Y-%m-%d %H:%M:%f', ?? / 1000.0, 'unixepoch') + WHERE typeof(??) = 'integer'`, + [table, column, column, column], + ); + +module.exports = createIrreversibleMigration(async function up(knex) { + if (!DatabaseInfo.isSQLite(knex)) { + logging.info('Skipping SQLite-only automation timestamp migration on MySQL'); + return; + } + + await convertDateColumn(knex, 'automation_runs', 'created_at'); + await convertDateColumn(knex, 'automation_runs', 'updated_at'); + await convertDateColumn(knex, 'automation_run_steps', 'created_at'); + await convertDateColumn(knex, 'automation_run_steps', 'updated_at'); + await convertDateColumn(knex, 'automation_run_steps', 'ready_at'); +}); diff --git a/ghost/core/server/lib/db-types/date.ts b/ghost/core/server/lib/db-types/date.ts index 61a67d27ef0a..7cc3218115da 100644 --- a/ghost/core/server/lib/db-types/date.ts +++ b/ghost/core/server/lib/db-types/date.ts @@ -3,6 +3,7 @@ import * as errors from '@tryghost/errors'; import { z } from 'zod'; const DATABASE_DATE_FORMAT = 'yyyy-MM-dd HH:mm:ss'; +const DATABASE_DATE_FORMAT_WITH_MILLISECONDS = 'yyyy-MM-dd HH:mm:ss.SSS'; const databaseDateInput = z.union([z.date(), z.string(), z.number()]); @@ -42,6 +43,9 @@ const parseDatabaseDate = (date: DatabaseDate): DateTime => { export const toDatabaseDate = (date: DatabaseDate): string => parseDatabaseDate(date).toFormat(DATABASE_DATE_FORMAT); +export const toDatabaseDateWithMilliseconds = (date: DatabaseDate): string => + parseDatabaseDate(date).toFormat(DATABASE_DATE_FORMAT_WITH_MILLISECONDS); + export const fromDatabaseDate = (date: DatabaseDate): Date => parseDatabaseDate(date).toJSDate(); // A zod codec for datetime columns: MySQL returns a Date, SQLite a string/number; diff --git a/ghost/core/server/services/automations/database-automations-repository-2.ts b/ghost/core/server/services/automations/database-automations-repository-2.ts index e1b0a03f451b..39582ef9c62a 100644 --- a/ghost/core/server/services/automations/database-automations-repository-2.ts +++ b/ghost/core/server/services/automations/database-automations-repository-2.ts @@ -4,6 +4,7 @@ import type { AutomationsRepository2, AutomationTriggerTierScope, } from './automations-repository-2'; +import { toDatabaseDateWithMilliseconds } from '../../lib/db-types/date'; type Connection = Knex | Knex.Transaction; @@ -46,6 +47,11 @@ export const createDatabaseAutomationsRepository2 = ({ }: Readonly<{ knex: Knex; }>) => { + const dateForDatabase = (date: Date): Date | string => + ['mysql', 'mysql2'].includes(knex.client.config.client) + ? date + : toDatabaseDateWithMilliseconds(date); + return { async findFirstActionRevisions(connection, memberStatus, memberTierIds) { const rows = await connection('automations as automation') @@ -122,9 +128,9 @@ export const createDatabaseAutomationsRepository2 = ({ await connection('automation_run_steps').insert( toInsert.map((step) => ({ ...step, - created_at: step.created_at, - updated_at: step.updated_at, - ready_at: step.ready_at, + created_at: dateForDatabase(step.created_at), + updated_at: dateForDatabase(step.updated_at), + ready_at: dateForDatabase(step.ready_at), })), ); } @@ -135,8 +141,8 @@ export const createDatabaseAutomationsRepository2 = ({ await connection('automation_runs').insert( toInsert.map((run) => ({ ...run, - created_at: run.created_at, - updated_at: run.updated_at, + created_at: dateForDatabase(run.created_at), + updated_at: dateForDatabase(run.updated_at), })), ); } diff --git a/ghost/core/server/services/automations/database-automations-repository.ts b/ghost/core/server/services/automations/database-automations-repository.ts index 7689aa754fb9..9446ccc25bf6 100644 --- a/ghost/core/server/services/automations/database-automations-repository.ts +++ b/ghost/core/server/services/automations/database-automations-repository.ts @@ -42,6 +42,7 @@ import { DbDate, fromDatabaseDate, toDatabaseDate, + toDatabaseDateWithMilliseconds, type DatabaseDate, } from '../../lib/db-types/date'; import { getStaleLockCutoff } from './stale-lock-cutoff'; @@ -504,7 +505,7 @@ export function createDatabaseAutomationsRepository({ steps: AutomationStepToRun[]; nextStepReadyAt: null | Date; }> { - return await knex.transaction((trx) => fetchAndLockSteps(trx, limit)); + return await knex.transaction((trx) => fetchAndLockSteps(trx, limit, knex)); }, async finishStepAndEnqueueNext(step: AutomationStepToRun): Promise { @@ -1057,6 +1058,7 @@ function createRunStep({ async function fetchAndLockSteps( trx: Knex.Transaction, limit: number, + knex: Knex, ): Promise<{ steps: AutomationStepToRun[]; nextStepReadyAt: null | Date; @@ -1074,6 +1076,7 @@ async function fetchAndLockSteps( const now = new Date(); const nowString = toDatabaseDate(now); + const readyAtCutoff = isMysql(knex) ? toDatabaseDate(now) : toDatabaseDateWithMilliseconds(now); const staleLockCutoff = getStaleLockCutoff(now); const staleLockCutoffString = toDatabaseDate(staleLockCutoff); const lockId = crypto.randomUUID(); @@ -1082,7 +1085,7 @@ async function fetchAndLockSteps( const candidates: ReadonlyArray<{ id: string }> = await trx('automation_run_steps') .select('id') .where('status', 'pending') - .where('ready_at', '<=', nowString) + .where('ready_at', '<=', readyAtCutoff) .where((builder) => { builder.whereNull('locked_by').orWhere('locked_at', '<', staleLockCutoffString); }) @@ -1108,7 +1111,7 @@ async function fetchAndLockSteps( .increment('step_attempts', 1) .whereIn('id', candidateIds) .where('status', 'pending') - .where('ready_at', '<=', nowString) + .where('ready_at', '<=', readyAtCutoff) .where((builder) => { builder.whereNull('locked_by').orWhere('locked_at', '<', staleLockCutoffString); }); diff --git a/ghost/test/integration/services/automations/automation-run-date-storage.test.ts b/ghost/test/integration/services/automations/automation-run-date-storage.test.ts new file mode 100644 index 000000000000..c8ac97a5b8b2 --- /dev/null +++ b/ghost/test/integration/services/automations/automation-run-date-storage.test.ts @@ -0,0 +1,241 @@ +import assert from 'node:assert/strict'; +import createKnex, { type Knex } from 'knex'; +import { vi } from 'vitest'; +import { configure } from '../../../../core/server/data/db/configure-knex'; +import { createDatabaseAutomationsRepository } from '../../../../core/server/services/automations/database-automations-repository'; +import { createDatabaseAutomationsRepository2 } from '../../../../core/server/services/automations/database-automations-repository-2'; +import { toDatabaseDateWithMilliseconds } from '../../../../core/server/lib/db-types/date'; + +const DatabaseInfo = require('@tryghost/database-info'); +const migration = require('../../../../core/server/data/migrations/versions/6.73/2026-10-21-21-22-05-convert-automation-dates-to-sqlite-text'); +const sqliteConfig = configure({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, +}); + +describe('SQLite automation date storage', function () { + let knex: Knex; + + beforeEach(async function () { + knex = createKnex({ + ...sqliteConfig, + connection: { filename: ':memory:' }, + }); + + await knex.schema.createTable('automations', (table) => { + table.string('id').primary(); + table.string('status').notNullable(); + table.string('trigger_tier_scope').notNullable(); + }); + await knex.schema.createTable('automation_actions', (table) => { + table.string('id').primary(); + table.string('type').notNullable(); + }); + await knex.schema.createTable('automation_action_revisions', (table) => { + table.string('id').primary(); + table.string('action_id').notNullable(); + table.integer('wait_hours'); + table.string('email_subject'); + table.text('email_lexical'); + table.string('email_design_setting_id'); + }); + await knex.schema.createTable('automation_runs', (table) => { + table.string('id').primary(); + table.dateTime('created_at').notNullable(); + table.dateTime('updated_at').notNullable(); + table.string('automation_id').notNullable(); + table.string('member_id').notNullable(); + table.string('member_email').notNullable(); + }); + await knex.schema.createTable('automation_run_steps', (table) => { + table.string('id').primary(); + table.dateTime('created_at').notNullable(); + table.dateTime('updated_at').notNullable(); + table.string('automation_run_id').notNullable(); + table.string('automation_action_revision_id').notNullable(); + table.dateTime('ready_at').notNullable(); + table.integer('step_attempts').notNullable().defaultTo(0); + table.string('status').notNullable().defaultTo('pending'); + table.string('locked_by'); + table.dateTime('locked_at'); + table.dateTime('started_at'); + }); + }); + + afterEach(async function () { + vi.useRealTimers(); + vi.restoreAllMocks(); + await knex.destroy(); + }); + + it('converts old integer timestamps to millisecond text and is idempotent', async function () { + const createdAt = new Date('2026-01-02T03:04:05.123Z'); + const readyAt = new Date('2026-01-02T03:05:05.456Z'); + await knex('automation_runs').insert({ + id: 'legacy-run', + created_at: createdAt, + updated_at: createdAt, + automation_id: 'automation', + member_id: 'member', + member_email: 'member@example.com', + }); + await knex('automation_run_steps').insert({ + id: 'legacy-step', + created_at: createdAt, + updated_at: createdAt, + automation_run_id: 'legacy-run', + automation_action_revision_id: 'revision', + ready_at: readyAt, + status: 'pending', + }); + + const beforeMigration = await knex('automation_run_steps') + .select(knex.raw('typeof(ready_at) as ready_at_type')) + .where('id', 'legacy-step') + .first<{ ready_at_type: string }>(); + assert.equal(beforeMigration?.ready_at_type, 'integer'); + + await migration.up({ connection: knex }); + await migration.up({ connection: knex }); + + const migratedRun = await knex('automation_runs') + .select( + 'created_at', + 'updated_at', + knex.raw('typeof(created_at) as created_at_type'), + knex.raw('typeof(updated_at) as updated_at_type'), + ) + .where('id', 'legacy-run') + .first(); + assert.equal(migratedRun.created_at_type, 'text'); + assert.equal(migratedRun.updated_at_type, 'text'); + assert.equal(migratedRun.created_at, toDatabaseDateWithMilliseconds(createdAt)); + + const migratedStep = await knex('automation_run_steps') + .select( + 'created_at', + 'updated_at', + 'ready_at', + knex.raw('typeof(created_at) as created_at_type'), + knex.raw('typeof(updated_at) as updated_at_type'), + knex.raw('typeof(ready_at) as ready_at_type'), + ) + .where('id', 'legacy-step') + .first(); + assert.equal(migratedStep.created_at_type, 'text'); + assert.equal(migratedStep.updated_at_type, 'text'); + assert.equal(migratedStep.ready_at_type, 'text'); + assert.equal(migratedStep.created_at, toDatabaseDateWithMilliseconds(createdAt)); + assert.equal(migratedStep.updated_at, toDatabaseDateWithMilliseconds(createdAt)); + assert.equal(migratedStep.ready_at, toDatabaseDateWithMilliseconds(readyAt)); + }); + + it('leaves SQLite values untouched on the MySQL migration path', async function () { + const createdAt = new Date('2026-01-02T03:04:05.123Z'); + await knex('automation_runs').insert({ + id: 'mysql-path-run', + created_at: createdAt, + updated_at: createdAt, + automation_id: 'automation', + member_id: 'member', + member_email: 'member@example.com', + }); + + vi.spyOn(DatabaseInfo, 'isSQLite').mockReturnValue(false); + await migration.up({ connection: knex }); + + const row = await knex('automation_runs') + .select(knex.raw('typeof(created_at) as created_at_type')) + .where('id', 'mysql-path-run') + .first<{ created_at_type: string }>(); + assert.equal(row?.created_at_type, 'integer'); + }); + + it('stores millisecond dates and handles the ready_at boundary', async function () { + const now = new Date('2026-10-21T12:00:00.500Z'); + const legacyReadyAt = new Date(now.getTime() - 60_000); + const boundaryReadyAt = new Date(now.getTime() - 1); + const futureReadyAt = new Date(now.getTime() + 1); + + await knex('automations').insert({ + id: 'automation', + status: 'active', + trigger_tier_scope: 'free', + }); + await knex('automation_actions').insert({ id: 'action', type: 'wait' }); + await knex('automation_action_revisions').insert({ + id: 'revision', + action_id: 'action', + wait_hours: 1, + }); + + await knex('automation_runs').insert({ + id: 'legacy-run', + created_at: now, + updated_at: now, + automation_id: 'automation', + member_id: 'member', + member_email: 'member@example.com', + }); + await knex('automation_run_steps').insert({ + id: 'legacy-step', + created_at: now, + updated_at: now, + automation_run_id: 'legacy-run', + automation_action_revision_id: 'revision', + ready_at: legacyReadyAt, + status: 'pending', + }); + await migration.up({ connection: knex }); + + const repository2 = createDatabaseAutomationsRepository2({ knex }); + await repository2.insertRuns(knex, [ + { + id: 'future-run', + created_at: now, + updated_at: now, + automation_id: 'automation', + member_id: 'member', + member_email: 'member@example.com', + }, + ]); + await repository2.insertRunSteps(knex, [ + { + id: 'boundary-step', + created_at: now, + updated_at: now, + automation_run_id: 'future-run', + automation_action_revision_id: 'revision', + ready_at: boundaryReadyAt, + }, + { + id: 'future-step', + created_at: now, + updated_at: now, + automation_run_id: 'future-run', + automation_action_revision_id: 'revision', + ready_at: futureReadyAt, + }, + ]); + + const storedFutureStep = await knex('automation_run_steps') + .select('ready_at', knex.raw('typeof(ready_at) as ready_at_type')) + .where('id', 'future-step') + .first<{ ready_at: string; ready_at_type: string }>(); + const repository = createDatabaseAutomationsRepository({ + knex, + fakeWaitHoursMultiplier: null, + }); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(now); + const { steps } = await repository.fetchAndLockSteps(10); + assert.deepEqual( + steps.map((step) => step.id), + ['legacy-step', 'boundary-step'], + ); + + assert.equal(storedFutureStep?.ready_at_type, 'text'); + assert.equal(storedFutureStep?.ready_at, toDatabaseDateWithMilliseconds(futureReadyAt)); + }); +});