From 7e940d79e124e9d606d6c44642356179318308ee Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Wed, 19 Aug 2026 09:56:50 +0100 Subject: [PATCH 1/4] Moved database date handling to shared server utilities ref https://linear.app/ghost/issue/BER-3851 Gift delivery needs the same UTC database timestamp formatting as automations. Keeping the implementation automation-owned would duplicate a database concern, so the existing formatter and parser now live beside the shared database date codec with their timezone behavior preserved. --- ghost/core/core/server/lib/db-date.ts | 27 +++++++++++++++++++ .../database-automations-repository.ts | 2 +- .../services/automations/database-date.ts | 26 ------------------ .../db-date.test.ts} | 4 +-- .../automations-repository.test.ts | 2 +- 5 files changed, 31 insertions(+), 30 deletions(-) delete mode 100644 ghost/core/core/server/services/automations/database-date.ts rename ghost/core/test/unit/server/{services/automations/database-date.test.ts => lib/db-date.test.ts} (94%) diff --git a/ghost/core/core/server/lib/db-date.ts b/ghost/core/core/server/lib/db-date.ts index 558352e5635..96c63305dc4 100644 --- a/ghost/core/core/server/lib/db-date.ts +++ b/ghost/core/core/server/lib/db-date.ts @@ -1,5 +1,32 @@ +import moment from 'moment'; +import * as errors from '@tryghost/errors'; import {z} from 'zod'; +export const DATABASE_DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss'; + +export type DatabaseDate = Date | string | number; + +// Raw Knex queries need this UTC datetime format for consistent MySQL and SQLite behavior. +export const toDatabaseDate = (date: Date | string): string => moment.utc(date).format(DATABASE_DATE_FORMAT); + +export const fromDatabaseDate = (date: DatabaseDate): Date => { + if (date instanceof Date) { + return new Date(date); + } + + if (typeof date === 'string') { + return moment.utc(date, DATABASE_DATE_FORMAT).toDate(); + } + + // Defense-in-depth for legacy SQLite rows stored as epoch milliseconds. + if (typeof date === 'number') { + return moment.utc(date).toDate(); + } + + const exhaustive: never = date; + throw new errors.InternalServerError({message: `Unexpected type for database date: ${exhaustive}`}); +}; + // A zod codec for datetime columns: MySQL returns a Date, SQLite a string/number; // normalise to a Date on read and pass a Date through on write. export const DbDate = z.codec(z.union([z.date(), z.string(), z.number()]), z.date(), { diff --git a/ghost/core/core/server/services/automations/database-automations-repository.ts b/ghost/core/core/server/services/automations/database-automations-repository.ts index 67b517bf32d..7c80ad94edc 100644 --- a/ghost/core/core/server/services/automations/database-automations-repository.ts +++ b/ghost/core/core/server/services/automations/database-automations-repository.ts @@ -22,7 +22,7 @@ import type { EditAutomationData, Page } from './automations-repository'; -import {fromDatabaseDate, toDatabaseDate, type DatabaseDate} from './database-date'; +import {fromDatabaseDate, toDatabaseDate, type DatabaseDate} from '../../lib/db-date'; import {getStaleLockCutoff} from './stale-lock-cutoff'; import type {ExclusifyUnion, ReadonlyDeep} from 'type-fest'; diff --git a/ghost/core/core/server/services/automations/database-date.ts b/ghost/core/core/server/services/automations/database-date.ts deleted file mode 100644 index 545f7b725cd..00000000000 --- a/ghost/core/core/server/services/automations/database-date.ts +++ /dev/null @@ -1,26 +0,0 @@ -import moment from 'moment'; -import * as errors from '@tryghost/errors'; - -export const DATABASE_DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss'; - -export type DatabaseDate = Date | string | number; - -export const toDatabaseDate = (date: Date | string): string => moment.utc(date).format(DATABASE_DATE_FORMAT); - -export const fromDatabaseDate = (date: DatabaseDate): Date => { - if (date instanceof Date) { - return new Date(date); - } - - if (typeof date === 'string') { - return moment.utc(date, DATABASE_DATE_FORMAT).toDate(); - } - - // Defense-in-depth for legacy SQLite rows stored as epoch milliseconds. - if (typeof date === 'number') { - return moment.utc(date).toDate(); - } - - const exhaustive: never = date; - throw new errors.InternalServerError({message: `Unexpected type for database date: ${exhaustive}`}); -}; diff --git a/ghost/core/test/unit/server/services/automations/database-date.test.ts b/ghost/core/test/unit/server/lib/db-date.test.ts similarity index 94% rename from ghost/core/test/unit/server/services/automations/database-date.test.ts rename to ghost/core/test/unit/server/lib/db-date.test.ts index 8ba86a91db7..5b0ccf37b60 100644 --- a/ghost/core/test/unit/server/services/automations/database-date.test.ts +++ b/ghost/core/test/unit/server/lib/db-date.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import {spawn} from 'node:child_process'; import {once} from 'node:events'; -import {fromDatabaseDate, toDatabaseDate} from '../../../../../core/server/services/automations/database-date'; +import {fromDatabaseDate, toDatabaseDate} from '../../../../core/server/lib/db-date'; describe('database date utilities', function () { const timezones = [ @@ -19,7 +19,7 @@ describe('database date utilities', function () { const runInOtherTimezones = async (toRun: string) => { // JSON.stringify does a good job wrapping strings in quotes and escaping. const s = JSON.stringify; - const modulePath = require.resolve('../../../../../core/server/services/automations/database-date'); + const modulePath = require.resolve('../../../../core/server/lib/db-date'); await Promise.all(timezones.map(async ({tz, expectedNaive}) => { const source = ` diff --git a/ghost/core/test/unit/server/services/automations/automations-repository.test.ts b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts index e390b2f5bd8..f54abfe6d64 100644 --- a/ghost/core/test/unit/server/services/automations/automations-repository.test.ts +++ b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts @@ -8,7 +8,7 @@ import {NON_EMPTY_EMAIL_LEXICAL} from '../../../../utils/automations-fixtures'; import ghostConfig from '../../../../../core/shared/config'; import {createDatabaseAutomationsRepository} from '../../../../../core/server/services/automations/database-automations-repository'; import type {AutomatedEmailEvents, AutomationAction, AutomationsRepository, AutomationStepToRun} from '../../../../../core/server/services/automations/automations-repository'; -import {DATABASE_DATE_FORMAT, fromDatabaseDate, toDatabaseDate} from '../../../../../core/server/services/automations/database-date'; +import {DATABASE_DATE_FORMAT, fromDatabaseDate, toDatabaseDate} from '../../../../../core/server/lib/db-date'; const HOUR_MS = 60 * 60 * 1000; const FAKE_WAIT_HOURS_MULTIPLIER = 2500; From 84295a6d18b716d0fc251653c864909df2b4d2a7 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Wed, 19 Aug 2026 10:27:05 +0100 Subject: [PATCH 2/4] Removed duplicate database date handling across seeders ref https://linear.app/ghost/issue/BER-3851 Seeders still maintained separate formatting and parsing paths after the shared utility was introduced. Using one canonical conversion boundary prevents timezone and precision behavior from drifting between raw Knex queries and generated data. --- .../automation-action-revisions-importer.ts | 6 ++--- .../importers/automation-actions-importer.ts | 8 +++--- .../automation-run-steps-importer.ts | 6 ++--- .../importers/automation-runs-importer.ts | 10 +++---- .../seeders/importers/automations-importer.ts | 6 ++--- .../importers/comment-reports-importer.js | 5 ++-- .../seeders/importers/comments-importer.js | 10 +++---- .../importers/email-batches-importer.js | 7 ++--- .../importers/email-recipients-importer.js | 19 ++++++------- .../data/seeders/importers/emails-importer.js | 12 ++++----- .../data/seeders/importers/labels-importer.js | 6 ++--- .../members-click-events-importer.js | 6 ++--- .../members-created-events-importer.js | 8 +++--- .../importers/members-feedback-importer.js | 9 ++++--- .../seeders/importers/members-importer.js | 8 +++--- .../members-login-events-importer.js | 6 ++--- .../importers/members-products-importer.js | 4 +-- .../members-status-events-importer.js | 5 ++-- .../members-stripe-customers-importer.js | 4 +-- ...stripe-customers-subscriptions-importer.js | 14 +++++----- .../members-subscribe-events-importer.js | 3 ++- ...rs-subscription-created-events-importer.js | 6 ++--- .../importers/offer-redemptions-importer.js | 10 +++---- .../data/seeders/importers/offers-importer.js | 6 ++--- .../data/seeders/importers/posts-importer.js | 8 +++--- .../data/seeders/importers/table-importer.ts | 4 +-- .../data/seeders/importers/tags-importer.js | 4 +-- .../data/seeders/importers/users-importer.js | 4 +-- .../importers/web-mentions-importer.js | 4 +-- .../data/seeders/utils/database-date.ts | 27 +++---------------- ghost/core/core/server/lib/db-date.ts | 2 +- .../data/seeders/data-generator.test.js | 15 ----------- .../core/test/unit/server/lib/db-date.test.ts | 8 ++++++ 33 files changed, 119 insertions(+), 141 deletions(-) diff --git a/ghost/core/core/server/data/seeders/importers/automation-action-revisions-importer.ts b/ghost/core/core/server/data/seeders/importers/automation-action-revisions-importer.ts index 5025ed7f463..b9349714799 100644 --- a/ghost/core/core/server/data/seeders/importers/automation-action-revisions-importer.ts +++ b/ghost/core/core/server/data/seeders/importers/automation-action-revisions-importer.ts @@ -2,7 +2,7 @@ import {faker} from '@faker-js/faker'; import errors from '@tryghost/errors'; import type {Knex} from 'knex'; import {TableImporter} from './table-importer'; -import * as databaseDate from '../utils/database-date'; +import {fromDatabaseDate, toDatabaseDate} from '../../../lib/db-date'; import {DEFAULT_EMAIL_DESIGN_SETTING_SLUG} from '../../../services/member-welcome-emails/constants'; type AutomationAction = { @@ -66,13 +66,13 @@ export class AutomationActionRevisionsImporter extends TableImporter const requestedStepCount = clamp(Math.floor(stepsPerRun) + extraStep, 1, actionCount); const runStepCount = fullPathGeneratedForAutomation.has(run.automation_id) ? requestedStepCount : actionCount; fullPathGeneratedForAutomation.add(run.automation_id); - const runCreatedAt = databaseDate.parse(run.created_at); + const runCreatedAt = fromDatabaseDate(run.created_at); const lastStepStatus = faker.helpers.arrayElement(['pending', 'finished', 'failed'] as const); for (let index = 0; index < runStepCount; index += 1) { const createdAt = new Date(runCreatedAt); createdAt.setHours(createdAt.getHours() + index); - const createdAtString = databaseDate.dateToDatabaseString(createdAt); + const createdAtString = toDatabaseDate(createdAt); const status = index === runStepCount - 1 ? lastStepStatus : 'finished'; const isTerminal = status !== 'pending'; diff --git a/ghost/core/core/server/data/seeders/importers/automation-runs-importer.ts b/ghost/core/core/server/data/seeders/importers/automation-runs-importer.ts index ea267402a4f..3aa45d3783d 100644 --- a/ghost/core/core/server/data/seeders/importers/automation-runs-importer.ts +++ b/ghost/core/core/server/data/seeders/importers/automation-runs-importer.ts @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import type {Knex} from 'knex'; import {TableImporter} from './table-importer'; import {parseEmailAddress} from '@tryghost/parse-email-address'; -import * as databaseDate from '../utils/database-date'; +import {fromDatabaseDate, toDatabaseDate} from '../../../lib/db-date'; type Automation = { id: string; @@ -73,8 +73,8 @@ export class AutomationRunsImporter extends TableImporter { status: faker.helpers.arrayElement(['active', 'inactive']), name, slug, - created_at: databaseDate.dateToDatabaseString(createdAt), - updated_at: databaseDate.dateToDatabaseString(createdAt) + created_at: toDatabaseDate(createdAt), + updated_at: toDatabaseDate(createdAt) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js b/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js index d18a086d38a..1b973c1fb3d 100644 --- a/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js +++ b/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js @@ -2,6 +2,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {luck} = require('../utils/random'); const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); class CommentReportsImporter extends TableImporter { static table = 'comment_reports'; @@ -57,8 +58,8 @@ class CommentReportsImporter extends TableImporter { id: this.fastFakeObjectId(), comment_id: this.model.id, member_id: reporter.id, - created_at: databaseDate.dateToDatabaseString(reportTime), - updated_at: databaseDate.dateToDatabaseString(reportTime) + created_at: toDatabaseDate(reportTime), + updated_at: toDatabaseDate(reportTime) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/comments-importer.js b/ghost/core/core/server/data/seeders/importers/comments-importer.js index 7fdaacceef8..a8aebcdeef2 100644 --- a/ghost/core/core/server/data/seeders/importers/comments-importer.js +++ b/ghost/core/core/server/data/seeders/importers/comments-importer.js @@ -2,7 +2,7 @@ const {faker} = require('@faker-js/faker'); const {TableImporter} = require('./table-importer'); const {luck} = require('../utils/random'); const generateEvents = require('../utils/event-generator'); -const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class CommentsImporter extends TableImporter { static table = 'comments'; @@ -24,7 +24,7 @@ class CommentsImporter extends TableImporter { setReferencedModel(model) { this.model = model; - const publishedAt = databaseDate.parse(model.published_at); + const publishedAt = fromDatabaseDate(model.published_at); this.commentIds = []; // Store [id, parent_id, timestamp] tuples for reply-to-reply @@ -37,7 +37,7 @@ class CommentsImporter extends TableImporter { endTime: new Date() }).sort((a, b) => a.getTime() - b.getTime()); // Sort chronologically so replies always come after their targets - this.possibleMembers = this.members.filter(member => databaseDate.parse(member.created_at) < publishedAt); + this.possibleMembers = this.members.filter(member => fromDatabaseDate(member.created_at) < publishedAt); } generate() { @@ -97,8 +97,8 @@ class CommentsImporter extends TableImporter { parent_id: parentId, in_reply_to_id: inReplyToId, status: 'published', - created_at: databaseDate.dateToDatabaseString(timestamp), - updated_at: databaseDate.dateToDatabaseString(timestamp), + created_at: toDatabaseDate(timestamp), + updated_at: toDatabaseDate(timestamp), html: `

${faker.lorem.sentence().replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`)}

` }; diff --git a/ghost/core/core/server/data/seeders/importers/email-batches-importer.js b/ghost/core/core/server/data/seeders/importers/email-batches-importer.js index a5028710528..3ba0c32596e 100644 --- a/ghost/core/core/server/data/seeders/importers/email-batches-importer.js +++ b/ghost/core/core/server/data/seeders/importers/email-batches-importer.js @@ -1,6 +1,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class EmailBatchesImporter extends TableImporter { static table = 'email_batches'; @@ -21,8 +22,8 @@ class EmailBatchesImporter extends TableImporter { } generate() { - const emailSentDate = databaseDate.parse(this.model.created_at); - const latestUpdatedDate = databaseDate.parse(this.model.created_at); + const emailSentDate = fromDatabaseDate(this.model.created_at); + const latestUpdatedDate = fromDatabaseDate(this.model.created_at); latestUpdatedDate.setHours(latestUpdatedDate.getHours() + 1); return { @@ -31,7 +32,7 @@ class EmailBatchesImporter extends TableImporter { mailgun_message_id: `${new Date().toISOString().split('.')[0].replace(/[^0-9]/g, '')}.${faker.string.hexadecimal({length: 16, prefix: '', casing: 'lower'})}@m.example.com`, status: 'submitted', // TODO: introduce failures created_at: this.model.created_at, - updated_at: databaseDate.dateToDatabaseString(databaseDate.randomBetween(emailSentDate, latestUpdatedDate)) + updated_at: toDatabaseDate(databaseDate.randomBetween(emailSentDate, latestUpdatedDate)) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js b/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js index 6bbcd62febb..e12409ef98e 100644 --- a/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js +++ b/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js @@ -2,6 +2,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const generateEvents = require('../utils/event-generator'); const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); const debug = require('@tryghost/debug')('EmailRecipientsImporter'); const emailStatus = { @@ -111,7 +112,7 @@ class EmailRecipientsImporter extends TableImporter { if (!(memberSubscribeEvent.created_at instanceof Date)) { // SQLite fix - memberSubscribeEvent.created_at = databaseDate.parse(memberSubscribeEvent.created_at); + memberSubscribeEvent.created_at = fromDatabaseDate(memberSubscribeEvent.created_at); } this.membersSubscribeEventsCreatedAtsByNewsletterId.get(memberSubscribeEvent.newsletter_id).push(memberSubscribeEvent.created_at.getTime()); } @@ -125,8 +126,8 @@ class EmailRecipientsImporter extends TableImporter { this.batchIndex = this.batch.index; // Shallow clone members list so we can shuffle and modify it - const earliestOpenTime = databaseDate.parse(this.batch.updated_at); - const latestOpenTime = databaseDate.parse(this.batch.updated_at); + const earliestOpenTime = fromDatabaseDate(this.batch.updated_at); + const latestOpenTime = fromDatabaseDate(this.batch.updated_at); latestOpenTime.setDate(latestOpenTime.getDate() + 14); // Get all members that were subscribed to this newsletter BEFORE the batch was sent @@ -164,7 +165,7 @@ class EmailRecipientsImporter extends TableImporter { } // The events are generated for a different time, so we need to move them to the batch time - timestamp = new Date(timestamp.getTime() - this.eventStartTimeUsed.getTime() + databaseDate.parse(this.batch.updated_at).getTime()); + timestamp = new Date(timestamp.getTime() - this.eventStartTimeUsed.getTime() + fromDatabaseDate(this.batch.updated_at).getTime()); if (timestamp > new Date()) { timestamp = new Date(); @@ -187,7 +188,7 @@ class EmailRecipientsImporter extends TableImporter { let deliveredTime; if (status === emailStatus.opened) { - const startDate = databaseDate.parse(this.batch.updated_at); + const startDate = fromDatabaseDate(this.batch.updated_at); const endDate = timestamp; deliveredTime = databaseDate.randomBetween(startDate, endDate); } @@ -198,10 +199,10 @@ class EmailRecipientsImporter extends TableImporter { email_id: this.model.id, batch_id: this.batch.id, member_id: member.id, - processed_at: databaseDate.dateToDatabaseString(this.batch.updated_at), - delivered_at: status === emailStatus.opened ? databaseDate.dateToDatabaseString(deliveredTime) : status === emailStatus.delivered ? databaseDate.dateToDatabaseString(timestamp) : null, - opened_at: status === emailStatus.opened ? databaseDate.dateToDatabaseString(timestamp) : null, - failed_at: status === emailStatus.failed ? databaseDate.dateToDatabaseString(timestamp) : null, + processed_at: toDatabaseDate(this.batch.updated_at), + delivered_at: status === emailStatus.opened ? toDatabaseDate(deliveredTime) : status === emailStatus.delivered ? toDatabaseDate(timestamp) : null, + opened_at: status === emailStatus.opened ? toDatabaseDate(timestamp) : null, + failed_at: status === emailStatus.failed ? toDatabaseDate(timestamp) : null, member_uuid: member.uuid, member_email: member.email, member_name: member.name diff --git a/ghost/core/core/server/data/seeders/importers/emails-importer.js b/ghost/core/core/server/data/seeders/importers/emails-importer.js index 47923c99085..d63af03c798 100644 --- a/ghost/core/core/server/data/seeders/importers/emails-importer.js +++ b/ghost/core/core/server/data/seeders/importers/emails-importer.js @@ -2,7 +2,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const generateEvents = require('../utils/event-generator'); const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class EmailsImporter extends TableImporter { static table = 'emails'; @@ -42,7 +42,7 @@ class EmailsImporter extends TableImporter { : this.newsletters[1]; } - const publishedAt = databaseDate.parse(this.model.published_at); + const publishedAt = fromDatabaseDate(this.model.published_at); const timestamp = luck(60) ? publishedAt : generateEvents({ @@ -55,7 +55,7 @@ class EmailsImporter extends TableImporter { const recipientCount = this.membersSubscribeEvents .filter(entry => entry.newsletter_id === newsletter.id) - .filter(entry => databaseDate.parse(entry.created_at) < timestamp).length; + .filter(entry => fromDatabaseDate(entry.created_at) < timestamp).length; const deliveredCount = Math.ceil(recipientCount * faker.number.float({ max: 1, min: 0.9, @@ -87,10 +87,10 @@ class EmailsImporter extends TableImporter { track_opens: true, track_clicks: true, feedback_enabled: true, - submitted_at: databaseDate.dateToDatabaseString(timestamp), + submitted_at: toDatabaseDate(timestamp), newsletter_id: newsletter.id, - created_at: databaseDate.dateToDatabaseString(timestamp), - updated_at: databaseDate.dateToDatabaseString(timestamp) + created_at: toDatabaseDate(timestamp), + updated_at: toDatabaseDate(timestamp) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/labels-importer.js b/ghost/core/core/server/data/seeders/importers/labels-importer.js index e8fef6bfdcb..1832df8f674 100644 --- a/ghost/core/core/server/data/seeders/importers/labels-importer.js +++ b/ghost/core/core/server/data/seeders/importers/labels-importer.js @@ -2,7 +2,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {slugify} = require('@tryghost/string'); const {blogStartDate} = require('../utils/blog-info'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); class LabelsImporter extends TableImporter { static table = 'labels'; @@ -30,8 +30,8 @@ class LabelsImporter extends TableImporter { id: this.fastFakeObjectId(), name: name, slug: `${slugify(name)}`, - created_at: databaseDate.dateToDatabaseString(blogStartDate), - updated_at: databaseDate.dateToDatabaseString(blogStartDate) + created_at: toDatabaseDate(blogStartDate), + updated_at: toDatabaseDate(blogStartDate) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js index f9c80d9f0a7..c788379bca0 100644 --- a/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class MembersClickEventsImporter extends TableImporter { static table = 'members_click_events'; @@ -50,7 +50,7 @@ class MembersClickEventsImporter extends TableImporter { } this.amount -= 1; - const openedAt = databaseDate.parse(this.model.opened_at); + const openedAt = fromDatabaseDate(this.model.opened_at); const laterOn = new Date(openedAt.getTime() + 1000 * 60 * 15); const clickTime = faker.date.between({from: openedAt.getTime(), to: laterOn.getTime()}); //added getTime here because it threw random errors @@ -61,7 +61,7 @@ class MembersClickEventsImporter extends TableImporter { min: 0, max: this.redirectList.length - 1 }))].id, - created_at: databaseDate.dateToDatabaseString(clickTime) + created_at: toDatabaseDate(clickTime) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js index 650a5b4f022..df71172c67d 100644 --- a/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class MembersCreatedEventsImporter extends TableImporter { static table = 'members_created_events'; @@ -48,8 +48,8 @@ class MembersCreatedEventsImporter extends TableImporter { }; if (source === 'member' && luck(30)) { - const memberCreatedAt = databaseDate.parse(this.model.created_at); - const post = this.posts.find(p => p.visibility === 'public' && databaseDate.parse(p.published_at) < memberCreatedAt); + const memberCreatedAt = fromDatabaseDate(this.model.created_at); + const post = this.posts.find(p => p.visibility === 'public' && fromDatabaseDate(p.published_at) < memberCreatedAt); if (post) { attribution = { attribution_id: post.id, @@ -93,7 +93,7 @@ class MembersCreatedEventsImporter extends TableImporter { return { id: this.fastFakeObjectId(), - created_at: databaseDate.dateToDatabaseString(this.model.created_at), + created_at: toDatabaseDate(this.model.created_at), member_id: this.model.id, source, ...attribution, diff --git a/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js b/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js index 3b43be3cc4d..0da06c044cc 100644 --- a/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js @@ -1,6 +1,7 @@ const {TableImporter} = require('./table-importer'); const {luck} = require('../utils/random'); const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class MembersFeedbackImporter extends TableImporter { static table = 'members_feedback'; @@ -24,8 +25,8 @@ class MembersFeedbackImporter extends TableImporter { return null; } - const openedAt = databaseDate.parse(this.model.opened_at); - const laterOn = databaseDate.parse(this.model.opened_at); + const openedAt = fromDatabaseDate(this.model.opened_at); + const laterOn = fromDatabaseDate(this.model.opened_at); laterOn.setMinutes(laterOn.getMinutes() + 60); const feedbackTime = databaseDate.randomBetween(openedAt, laterOn); @@ -35,8 +36,8 @@ class MembersFeedbackImporter extends TableImporter { score: luck(70) ? 1 : 0, member_id: this.model.member_id, post_id: postId, - created_at: databaseDate.dateToDatabaseString(feedbackTime), - updated_at: databaseDate.dateToDatabaseString(feedbackTime) + created_at: toDatabaseDate(feedbackTime), + updated_at: toDatabaseDate(feedbackTime) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/members-importer.js b/ghost/core/core/server/data/seeders/importers/members-importer.js index 11bfcf3dbe8..a4f775fee32 100644 --- a/ghost/core/core/server/data/seeders/importers/members-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-importer.js @@ -4,7 +4,7 @@ const {faker: americanFaker} = require('@faker-js/faker/locale/en_US'); const {blogStartDate: startTime} = require('../utils/blog-info'); const generateEvents = require('../utils/event-generator'); const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); const debug = require('@tryghost/debug')('MembersImporter'); class MembersImporter extends TableImporter { @@ -88,9 +88,9 @@ class MembersImporter extends TableImporter { email_opened_count: 0, email_open_rate: null, // 40% of users logged in within a week, 60% sometime since registering - last_seen_at: luck(40) ? databaseDate.dateToDatabaseString(faker.date.recent({days: 7})) : databaseDate.dateToDatabaseString(faker.date.between({from: timestamp, to: new Date()})), - created_at: databaseDate.dateToDatabaseString(timestamp), - updated_at: databaseDate.dateToDatabaseString(timestamp) + last_seen_at: luck(40) ? toDatabaseDate(faker.date.recent({days: 7})) : toDatabaseDate(faker.date.between({from: timestamp, to: new Date()})), + created_at: toDatabaseDate(timestamp), + updated_at: toDatabaseDate(timestamp) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js index 3cc32b220b9..9f020569aec 100644 --- a/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {luck} = require('../utils/random'); const generateEvents = require('../utils/event-generator'); -const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class MembersLoginEventsImporter extends TableImporter { static table = 'members_login_events'; @@ -34,7 +34,7 @@ class MembersLoginEventsImporter extends TableImporter { setReferencedModel(model) { this.model = model; - const memberCreatedAt = databaseDate.parse(model.created_at); + const memberCreatedAt = fromDatabaseDate(model.created_at); const endDate = new Date(); const daysBetween = Math.ceil((endDate.valueOf() - memberCreatedAt.valueOf()) / (1000 * 60 * 60 * 24)); @@ -60,7 +60,7 @@ class MembersLoginEventsImporter extends TableImporter { } return { id: this.fastFakeObjectId(), - created_at: databaseDate.dateToDatabaseString(timestamp), + created_at: toDatabaseDate(timestamp), member_id: this.model.id }; } diff --git a/ghost/core/core/server/data/seeders/importers/members-products-importer.js b/ghost/core/core/server/data/seeders/importers/members-products-importer.js index 893e2098be2..7fed09eff64 100644 --- a/ghost/core/core/server/data/seeders/importers/members-products-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-products-importer.js @@ -1,7 +1,7 @@ const {faker} = require('@faker-js/faker'); const {TableImporter} = require('./table-importer'); const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); class MembersProductsImporter extends TableImporter { static table = 'members_products'; @@ -34,7 +34,7 @@ class MembersProductsImporter extends TableImporter { member_id: this.model.id, product_id: this.getProduct().id, sort_order: 0, - expiry_at: this.model.status === 'paid' ? null : (luck(50) ? null : databaseDate.dateToDatabaseString(faker.date.future())) + expiry_at: this.model.status === 'paid' ? null : (luck(50) ? null : toDatabaseDate(faker.date.future())) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js index d18a602cb5b..7fad098b990 100644 --- a/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js @@ -1,5 +1,6 @@ const {TableImporter} = require('./table-importer'); const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); class MembersStatusEventsImporter extends TableImporter { static table = 'members_status_events'; @@ -31,7 +32,7 @@ class MembersStatusEventsImporter extends TableImporter { member_id: model.id, from_status: null, to_status: 'free', - created_at: databaseDate.dateToDatabaseString(model.created_at) + created_at: toDatabaseDate(model.created_at) }]; if (model.status !== 'free') { this.events.push({ @@ -39,7 +40,7 @@ class MembersStatusEventsImporter extends TableImporter { member_id: model.id, from_status: 'free', to_status: model.status, - created_at: databaseDate.dateToDatabaseString(databaseDate.randomBetween(model.created_at, new Date())) + created_at: toDatabaseDate(databaseDate.randomBetween(model.created_at, new Date())) }); } } diff --git a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js index 34203ad2d2a..1970c99f158 100644 --- a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js @@ -1,6 +1,6 @@ const {faker} = require('@faker-js/faker'); const {TableImporter} = require('./table-importer'); -const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate} = require('../../../lib/db-date'); class MembersStripeCustomersImporter extends TableImporter { static table = 'members_stripe_customers'; @@ -35,7 +35,7 @@ class MembersStripeCustomersImporter extends TableImporter { // Only 30% of free members should have a stripe customer = have had a subscription in the past or tried to subscribe // The number should increase the older the member is - const daysSinceMemberCreated = Math.floor((new Date() - databaseDate.parse(this.model.created_at)) / (1000 * 60 * 60 * 24)); + const daysSinceMemberCreated = Math.floor((new Date() - fromDatabaseDate(this.model.created_at)) / (1000 * 60 * 60 * 24)); const shouldHaveStripeCustomer = faker.number.int({min: 0, max: 100}) < Math.max(Math.min(daysSinceMemberCreated / 60, 15), 2); if (!shouldHaveStripeCustomer) { diff --git a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js index 766eb2f6993..1e3be210c5e 100644 --- a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js @@ -1,8 +1,8 @@ const {faker} = require('@faker-js/faker'); const {TableImporter} = require('./table-importer'); -const databaseDate = require('../utils/database-date'); const generateEvents = require('../utils/event-generator'); const {luck} = require('../utils/random'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class MembersStripeCustomersSubscriptionsImporter extends TableImporter { static table = 'members_stripe_customers_subscriptions'; @@ -95,7 +95,7 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { (isMonthly ? price.interval === 'month' : price.interval === 'year'); }); const mrr = createValid ? (isMonthly ? stripePrice.amount : Math.floor(stripePrice.amount / 12)) : 0; - const memberCreatedAt = databaseDate.parse(member.created_at); + const memberCreatedAt = fromDatabaseDate(member.created_at); const referenceEndDate = this.lastSubscriptionStart ?? new Date(); @@ -193,8 +193,8 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { validStatusses.push({ status: 'trialing', cancel_at_period_end: false, - trial_end_at: databaseDate.dateToDatabaseString(endDate), - trial_start_at: databaseDate.dateToDatabaseString(startDate) + trial_end_at: toDatabaseDate(endDate), + trial_start_at: toDatabaseDate(startDate) }); } @@ -236,8 +236,8 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { customer_id: customer.customer_id, subscription_id: `sub_${faker.string.alphanumeric(14)}`, stripe_price_id: stripePrice.stripe_price_id, - start_date: databaseDate.dateToDatabaseString(startDate), - created_at: databaseDate.dateToDatabaseString(startDate), + start_date: toDatabaseDate(startDate), + created_at: toDatabaseDate(startDate), mrr, plan_id: stripeProduct.stripe_product_id, plan_nickname: `${ghostProduct.name} - ${stripePrice.nickname}`, @@ -248,7 +248,7 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { // Defaults status: 'active', cancel_at_period_end: false, - current_period_end: databaseDate.dateToDatabaseString(endDate), + current_period_end: toDatabaseDate(endDate), // Override ...status diff --git a/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js index e05856e8d37..c9cb9f6b409 100644 --- a/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js @@ -1,6 +1,7 @@ const {TableImporter} = require('./table-importer'); const {luck} = require('../utils/random'); const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); class MembersSubscribeEventsImporter extends TableImporter { static table = 'members_subscribe_events'; @@ -50,7 +51,7 @@ class MembersSubscribeEventsImporter extends TableImporter { return null; } - const createdAt = databaseDate.dateToDatabaseString(databaseDate.randomBetween(this.model.created_at, new Date())); + const createdAt = toDatabaseDate(databaseDate.randomBetween(this.model.created_at, new Date())); const newsletterId = this.newsletters[count % this.newsletters.length].id; return { diff --git a/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js index 0704cebb035..0611c14e3a8 100644 --- a/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate} = require('../../../lib/db-date'); class MembersSubscriptionCreatedEventsImporter extends TableImporter { static table = 'members_subscription_created_events'; @@ -48,8 +48,8 @@ class MembersSubscriptionCreatedEventsImporter extends TableImporter { }; if (luck(30)) { - const createdAt = databaseDate.parse(this.model.created_at); - const post = this.posts.find(p => p.visibility === 'public' && databaseDate.parse(p.published_at) < createdAt); + const createdAt = fromDatabaseDate(this.model.created_at); + const post = this.posts.find(p => p.visibility === 'public' && fromDatabaseDate(p.published_at) < createdAt); if (post) { attribution = { attribution_id: post.id, diff --git a/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js b/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js index 016a44bd882..9d9cc484206 100644 --- a/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js +++ b/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js @@ -1,7 +1,7 @@ const {faker} = require('@faker-js/faker'); const errors = require('@tryghost/errors'); const {TableImporter} = require('./table-importer'); -const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class OfferRedemptionsImporter extends TableImporter { static table = 'offer_redemptions'; @@ -53,7 +53,7 @@ class OfferRedemptionsImporter extends TableImporter { this.subscriptionPool.push({ memberId, subscriptionId: subscription.id, - subscriptionCreatedAt: databaseDate.parse(subscription.created_at), + subscriptionCreatedAt: fromDatabaseDate(subscription.created_at), redemptionEndAt: this.getRedemptionEndDate(subscription.current_period_end), availableOffers: [...matchingOffers], lastRedeemedAt: null @@ -78,7 +78,7 @@ class OfferRedemptionsImporter extends TableImporter { getRedemptionEndDate(currentPeriodEnd) { const now = new Date(); - const endDate = currentPeriodEnd ? databaseDate.parse(currentPeriodEnd) : now; + const endDate = currentPeriodEnd ? fromDatabaseDate(currentPeriodEnd) : now; return endDate > now ? now : endDate; } @@ -86,7 +86,7 @@ class OfferRedemptionsImporter extends TableImporter { getCreatedAt(subscriptionState, offer) { const candidateEarliest = new Date(Math.max( subscriptionState.subscriptionCreatedAt.valueOf(), - databaseDate.parse(offer.created_at).valueOf(), + fromDatabaseDate(offer.created_at).valueOf(), subscriptionState.lastRedeemedAt ? subscriptionState.lastRedeemedAt.valueOf() + 1000 : 0 )); const earliest = new Date(Math.min( @@ -98,7 +98,7 @@ class OfferRedemptionsImporter extends TableImporter { subscriptionState.lastRedeemedAt = createdAt; - return databaseDate.dateToDatabaseString(createdAt); + return toDatabaseDate(createdAt); } generate() { diff --git a/ghost/core/core/server/data/seeders/importers/offers-importer.js b/ghost/core/core/server/data/seeders/importers/offers-importer.js index c547eeaf795..ce3e84b6534 100644 --- a/ghost/core/core/server/data/seeders/importers/offers-importer.js +++ b/ghost/core/core/server/data/seeders/importers/offers-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {slugify} = require('@tryghost/string'); const {blogStartDate} = require('../utils/blog-info'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); const offerTemplates = [{ name: 'Black Friday', @@ -107,8 +107,8 @@ class OffersImporter extends TableImporter { portal_title: name, portal_description: template.description, redemption_type: 'signup', - created_at: databaseDate.dateToDatabaseString(blogStartDate), - updated_at: databaseDate.dateToDatabaseString(blogStartDate) + created_at: toDatabaseDate(blogStartDate), + updated_at: toDatabaseDate(blogStartDate) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/posts-importer.js b/ghost/core/core/server/data/seeders/importers/posts-importer.js index 7757c3d2bba..82746c3d0de 100644 --- a/ghost/core/core/server/data/seeders/importers/posts-importer.js +++ b/ghost/core/core/server/data/seeders/importers/posts-importer.js @@ -2,7 +2,7 @@ const {faker} = require('@faker-js/faker'); const {slugify} = require('@tryghost/string'); const {luck} = require('../utils/random'); const {TableImporter} = require('./table-importer'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); class PostsImporter extends TableImporter { static table = 'posts'; @@ -53,9 +53,9 @@ class PostsImporter extends TableImporter { return { id, - created_at: databaseDate.dateToDatabaseString(timestamp), - updated_at: databaseDate.dateToDatabaseString(timestamp), - published_at: status === 'published' ? databaseDate.dateToDatabaseString(timestamp) : status === 'scheduled' ? databaseDate.dateToDatabaseString(faker.date.soon({days: 5, refDate: timestamp})) : null, + created_at: toDatabaseDate(timestamp), + updated_at: toDatabaseDate(timestamp), + published_at: status === 'published' ? toDatabaseDate(timestamp) : status === 'scheduled' ? toDatabaseDate(faker.date.soon({days: 5, refDate: timestamp})) : null, uuid: faker.string.uuid(), comment_id: this.type === 'post' ? id : null, title: title, diff --git a/ghost/core/core/server/data/seeders/importers/table-importer.ts b/ghost/core/core/server/data/seeders/importers/table-importer.ts index 3389bf21bf2..48d8b6287bc 100644 --- a/ghost/core/core/server/data/seeders/importers/table-importer.ts +++ b/ghost/core/core/server/data/seeders/importers/table-importer.ts @@ -1,5 +1,5 @@ import debugFactory from '@tryghost/debug'; -import * as databaseDate from '../utils/database-date'; +import {toDatabaseDate} from '../../../lib/db-date'; import path from 'node:path'; import fs from 'node:fs'; import papaparse from 'papaparse'; @@ -123,7 +123,7 @@ export abstract class TableImporter< if (typeof value === 'boolean') { mutableObj[key] = value ? 1 : 0; } else if (value instanceof Date) { - mutableObj[key] = databaseDate.dateToDatabaseString(value); + mutableObj[key] = toDatabaseDate(value); } else if (value === null) { mutableObj[key] = '\\N'; } diff --git a/ghost/core/core/server/data/seeders/importers/tags-importer.js b/ghost/core/core/server/data/seeders/importers/tags-importer.js index 6f1a2b62751..36c0fbe8c0c 100644 --- a/ghost/core/core/server/data/seeders/importers/tags-importer.js +++ b/ghost/core/core/server/data/seeders/importers/tags-importer.js @@ -1,7 +1,7 @@ const {faker} = require('@faker-js/faker'); const {slugify} = require('@tryghost/string'); const {TableImporter} = require('./table-importer'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); class TagsImporter extends TableImporter { static table = 'tags'; @@ -32,7 +32,7 @@ class TagsImporter extends TableImporter { name: name, slug: slugify(name), description: faker.lorem.sentence(), - created_at: databaseDate.dateToDatabaseString(faker.date.between({from: threeYearsAgo, to: twoYearsAgo})) + created_at: toDatabaseDate(faker.date.between({from: threeYearsAgo, to: twoYearsAgo})) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/users-importer.js b/ghost/core/core/server/data/seeders/importers/users-importer.js index 1ae7d07e961..4b904522bf9 100644 --- a/ghost/core/core/server/data/seeders/importers/users-importer.js +++ b/ghost/core/core/server/data/seeders/importers/users-importer.js @@ -2,7 +2,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {slugify} = require('@tryghost/string'); const security = require('@tryghost/security'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); class UsersImporter extends TableImporter { static table = 'users'; @@ -24,7 +24,7 @@ class UsersImporter extends TableImporter { password: await security.password.hash(faker.color.human()), email: faker.internet.email({firstName, lastName}), profile_image: faker.image.avatar(), - created_at: databaseDate.dateToDatabaseString(faker.date.between({from: new Date(2016, 0), to: new Date()})) + created_at: toDatabaseDate(faker.date.between({from: new Date(2016, 0), to: new Date()})) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/web-mentions-importer.js b/ghost/core/core/server/data/seeders/importers/web-mentions-importer.js index 2e1793a9ed7..803a912c5d9 100644 --- a/ghost/core/core/server/data/seeders/importers/web-mentions-importer.js +++ b/ghost/core/core/server/data/seeders/importers/web-mentions-importer.js @@ -1,6 +1,6 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); class WebMentionsImporter extends TableImporter { static table = 'mentions'; @@ -31,7 +31,7 @@ class WebMentionsImporter extends TableImporter { target: `${this.baseUrl}`, resource_id: null, resource_type: null, - created_at: databaseDate.dateToDatabaseString(faker.date.past()), + created_at: toDatabaseDate(faker.date.past()), payload: JSON.stringify({}), deleted: 0, verified: 1 diff --git a/ghost/core/core/server/data/seeders/utils/database-date.ts b/ghost/core/core/server/data/seeders/utils/database-date.ts index 39a2512e3c7..7aa42e07585 100644 --- a/ghost/core/core/server/data/seeders/utils/database-date.ts +++ b/ghost/core/core/server/data/seeders/utils/database-date.ts @@ -1,30 +1,9 @@ import {faker} from '@faker-js/faker'; - -const databaseDatePattern = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/; - -export function dateToDatabaseString(date: Date | string): string { - if (typeof date === 'string') { - // SQLite fix when reusing other dates from the db - return date; - } - return date.toISOString().replace('Z', '').replace('T', ' '); -} - -export function parse(date: Date | string | number): Date { - if (date instanceof Date) { - return new Date(date); - } - - if (typeof date === 'string' && databaseDatePattern.test(date)) { - return new Date(date.replace(' ', 'T') + 'Z'); - } - - return new Date(date); -} +import {fromDatabaseDate} from '../../../lib/db-date'; export function randomBetween(start: Date | string | number, end: Date | string | number): Date { - const earliest = parse(start); - const latest = parse(end); + const earliest = fromDatabaseDate(start); + const latest = fromDatabaseDate(end); return latest > earliest ? faker.date.between({from: earliest, to: latest}) : earliest; } diff --git a/ghost/core/core/server/lib/db-date.ts b/ghost/core/core/server/lib/db-date.ts index 96c63305dc4..5deff13ee86 100644 --- a/ghost/core/core/server/lib/db-date.ts +++ b/ghost/core/core/server/lib/db-date.ts @@ -15,7 +15,7 @@ export const fromDatabaseDate = (date: DatabaseDate): Date => { } if (typeof date === 'string') { - return moment.utc(date, DATABASE_DATE_FORMAT).toDate(); + return moment.utc(date).toDate(); } // Defense-in-depth for legacy SQLite rows stored as epoch milliseconds. diff --git a/ghost/core/test/unit/server/data/seeders/data-generator.test.js b/ghost/core/test/unit/server/data/seeders/data-generator.test.js index 4fbb051020a..bbe072bab5e 100644 --- a/ghost/core/test/unit/server/data/seeders/data-generator.test.js +++ b/ghost/core/test/unit/server/data/seeders/data-generator.test.js @@ -479,21 +479,6 @@ describe('Importer', function () { }); describe('Events Generator', function () { - it('Parses database timestamps as UTC in non-UTC timezones', function () { - const originalTZ = process.env.TZ; - try { - process.env.TZ = 'America/New_York'; - const result = databaseDate.parse('2026-03-26 11:50:00.000'); - assert.equal(result.toISOString(), '2026-03-26T11:50:00.000Z'); - } finally { - if (originalTZ === undefined) { - delete process.env.TZ; - } else { - process.env.TZ = originalTZ; - } - } - }); - it('Returns the start date when a range is inverted', function () { const startDate = new Date('2026-03-26T11:50:00.000Z'); const endDate = new Date('2026-03-26T10:00:00.000Z'); diff --git a/ghost/core/test/unit/server/lib/db-date.test.ts b/ghost/core/test/unit/server/lib/db-date.test.ts index 5b0ccf37b60..e197bb212a1 100644 --- a/ghost/core/test/unit/server/lib/db-date.test.ts +++ b/ghost/core/test/unit/server/lib/db-date.test.ts @@ -85,6 +85,14 @@ describe('database date utilities', function () { assert.deepEqual(fromDatabaseDate('2020-01-01T12:34:56.000Z'), new Date('2020-01-01T12:34:56.000Z')); }); + it('preserves fractional seconds in database date strings', function () { + assert.deepEqual(fromDatabaseDate('2020-01-01 12:34:56.123'), new Date('2020-01-01T12:34:56.123Z')); + }); + + it('respects timezone offsets in ISO date strings', function () { + assert.deepEqual(fromDatabaseDate('2020-01-01T12:34:56.123-04:00'), new Date('2020-01-01T16:34:56.123Z')); + }); + it('converts strings to Date objects, parsing as UTC, in other system timezones', async function () { await runInOtherTimezones(` assert.deepEqual(fromDatabaseDate('2020-01-01 12:34:56'), new Date('2020-01-01T12:34:56.000Z')); From b978f459934ee480054b85b762916830078f201f Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Wed, 19 Aug 2026 12:03:09 +0100 Subject: [PATCH 3/4] Changed shared database date helpers to use luxon ref https://linear.app/ghost/issue/BER-3851 moment is legacy in Ghost and new code should use luxon, so the shared helpers in core/server/lib/db-date.ts now parse and format with luxon instead of carrying the moment implementation over from the automations service. While consolidating, the helpers now share one parse path with the DbDate zod codec so SQLite datetime strings decode as UTC everywhere, and invalid inputs throw instead of silently producing "Invalid date" strings or local-time shifts. @types/luxon is added to ghost/core so the TypeScript build can see luxon's types. --- ghost/core/core/server/lib/db-date.ts | 53 +++++++++++++------ ghost/core/package.json | 1 + .../core/test/unit/server/lib/db-date.test.ts | 45 +++++++++++++++- .../automations-repository.test.ts | 4 +- pnpm-lock.yaml | 3 ++ 5 files changed, 85 insertions(+), 21 deletions(-) diff --git a/ghost/core/core/server/lib/db-date.ts b/ghost/core/core/server/lib/db-date.ts index 5deff13ee86..be86206be43 100644 --- a/ghost/core/core/server/lib/db-date.ts +++ b/ghost/core/core/server/lib/db-date.ts @@ -1,35 +1,54 @@ -import moment from 'moment'; +import {DateTime} from 'luxon'; import * as errors from '@tryghost/errors'; import {z} from 'zod'; -export const DATABASE_DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss'; +const DATABASE_DATE_FORMAT = 'yyyy-MM-dd HH:mm:ss'; -export type DatabaseDate = Date | string | number; +const databaseDateInput = z.union([z.date(), z.string(), z.number()]); -// Raw Knex queries need this UTC datetime format for consistent MySQL and SQLite behavior. -export const toDatabaseDate = (date: Date | string): string => moment.utc(date).format(DATABASE_DATE_FORMAT); +export type DatabaseDate = z.input; + +const parseDatabaseDateString = (value: string): DateTime => { + const sql = DateTime.fromSQL(value, {zone: 'utc'}); + + return sql.isValid ? sql : DateTime.fromISO(value, {zone: 'utc'}); +}; + +const parseDatabaseDate = (date: DatabaseDate): DateTime => { + const input = databaseDateInput.safeParse(date); -export const fromDatabaseDate = (date: DatabaseDate): Date => { - if (date instanceof Date) { - return new Date(date); + if (!input.success) { + throw new errors.InternalServerError({message: 'Invalid database date'}); } - if (typeof date === 'string') { - return moment.utc(date).toDate(); + let parsed: DateTime; + + if (typeof input.data === 'string') { + parsed = parseDatabaseDateString(input.data); + } else if (typeof input.data === 'number') { + parsed = DateTime.fromMillis(input.data, {zone: 'utc'}); + } else { + parsed = DateTime.fromJSDate(input.data, {zone: 'utc'}); } - // Defense-in-depth for legacy SQLite rows stored as epoch milliseconds. - if (typeof date === 'number') { - return moment.utc(date).toDate(); + if (!parsed.isValid) { + throw new errors.InternalServerError({message: 'Invalid database date'}); } - const exhaustive: never = date; - throw new errors.InternalServerError({message: `Unexpected type for database date: ${exhaustive}`}); + return parsed; }; +// Raw Knex queries need this UTC datetime format for consistent MySQL and SQLite behavior. +export const toDatabaseDate = (date: DatabaseDate): string => parseDatabaseDate(date).toFormat(DATABASE_DATE_FORMAT); + +export const fromDatabaseDate = (date: DatabaseDate): Date => parseDatabaseDate(date).toJSDate(); + // A zod codec for datetime columns: MySQL returns a Date, SQLite a string/number; // normalise to a Date on read and pass a Date through on write. -export const DbDate = z.codec(z.union([z.date(), z.string(), z.number()]), z.date(), { - decode: value => new Date(value), +// On SQLite, Knex binds the Date that DbDate passes through as epoch milliseconds (stored as +// INTEGER) while toDatabaseDate writes TEXT; SQLite orders INTEGER before TEXT, so never compare +// or order those two representations. +export const DbDate = z.codec(databaseDateInput, z.date(), { + decode: fromDatabaseDate, encode: date => date }); diff --git a/ghost/core/package.json b/ghost/core/package.json index e1c26132830..f1fddf13d47 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -264,6 +264,7 @@ "@types/jsonwebtoken": "9.0.10", "@types/lodash": "4.17.25", "@types/lodash-es": "4.17.12", + "@types/luxon": "catalog:", "@types/mime-types": "3.0.1", "@types/nconf": "catalog:", "@types/node": "catalog:", diff --git a/ghost/core/test/unit/server/lib/db-date.test.ts b/ghost/core/test/unit/server/lib/db-date.test.ts index e197bb212a1..6d1e6ab8257 100644 --- a/ghost/core/test/unit/server/lib/db-date.test.ts +++ b/ghost/core/test/unit/server/lib/db-date.test.ts @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; import {spawn} from 'node:child_process'; import {once} from 'node:events'; -import {fromDatabaseDate, toDatabaseDate} from '../../../../core/server/lib/db-date'; +import * as errors from '@tryghost/errors'; +import {DbDate, fromDatabaseDate, toDatabaseDate} from '../../../../core/server/lib/db-date'; describe('database date utilities', function () { const timezones = [ @@ -24,7 +25,7 @@ describe('database date utilities', function () { await Promise.all(timezones.map(async ({tz, expectedNaive}) => { const source = ` const assert = require('node:assert/strict'); - const {fromDatabaseDate, toDatabaseDate} = require(${s(modulePath)}); + const {DbDate, fromDatabaseDate, toDatabaseDate} = require(${s(modulePath)}); assert.deepEqual(new Date('2020-01-01 12:34:56'), new Date(${s(expectedNaive)}), 'test setup'); @@ -41,6 +42,18 @@ describe('database date utilities', function () { })); }; + describe('DbDate', function () { + it('decodes database date strings as UTC', function () { + assert.deepEqual(DbDate.decode('2020-01-01 12:34:56'), new Date('2020-01-01T12:34:56.000Z')); + }); + + it('decodes database date strings as UTC in other system timezones', async function () { + await runInOtherTimezones(` + assert.deepEqual(DbDate.decode('2020-01-01 12:34:56'), new Date('2020-01-01T12:34:56.000Z')); + `); + }); + }); + describe('toDatabaseDate', function () { it('converts Dates to database date strings', function () { const input = new Date('2024-06-01T12:34:56Z'); @@ -48,6 +61,10 @@ describe('database date utilities', function () { assert.strictEqual(result, '2024-06-01 12:34:56'); }); + it('converts epoch milliseconds to database date strings', function () { + assert.strictEqual(toDatabaseDate(1577882096000), '2020-01-01 12:34:56'); + }); + it('converts Zulu date strings to database date strings', function () { const input = '2024-06-01T12:34:56Z'; const result = toDatabaseDate(input); @@ -71,6 +88,11 @@ describe('database date utilities', function () { assert.deepEqual(toDatabaseDate('2020-01-01 12:34:56'), '2020-01-01 12:34:56'); `); }); + + it('rejects invalid dates', function () { + assert.throws(() => toDatabaseDate('not-a-date'), errors.InternalServerError); + assert.throws(() => toDatabaseDate(new Date('not-a-date')), errors.InternalServerError); + }); }); describe('fromDatabaseDate', function () { @@ -93,14 +115,33 @@ describe('database date utilities', function () { assert.deepEqual(fromDatabaseDate('2020-01-01T12:34:56.123-04:00'), new Date('2020-01-01T16:34:56.123Z')); }); + it('respects timezone offsets in SQL date strings', function () { + assert.deepEqual(fromDatabaseDate('2020-01-01 12:34:56+00:00'), new Date('2020-01-01T12:34:56.000Z')); + }); + it('converts strings to Date objects, parsing as UTC, in other system timezones', async function () { await runInOtherTimezones(` assert.deepEqual(fromDatabaseDate('2020-01-01 12:34:56'), new Date('2020-01-01T12:34:56.000Z')); + assert.deepEqual(fromDatabaseDate('2020-01-01 12:34:56.123'), new Date('2020-01-01T12:34:56.123Z')); `); }); it('converts numbers to Date objects', function () { assert.deepEqual(fromDatabaseDate(1577882096000), new Date('2020-01-01T12:34:56.000Z')); }); + + it('rejects malformed strings', function () { + assert.throws(() => fromDatabaseDate('not-a-date'), errors.InternalServerError); + }); + + it('rejects invalid Date objects', function () { + assert.throws(() => fromDatabaseDate(new Date('not-a-date')), errors.InternalServerError); + }); + + it('rejects non-finite numbers', function () { + for (const input of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + assert.throws(() => fromDatabaseDate(input), errors.InternalServerError); + } + }); }); }); diff --git a/ghost/core/test/unit/server/services/automations/automations-repository.test.ts b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts index f54abfe6d64..612912293be 100644 --- a/ghost/core/test/unit/server/services/automations/automations-repository.test.ts +++ b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts @@ -8,7 +8,7 @@ import {NON_EMPTY_EMAIL_LEXICAL} from '../../../../utils/automations-fixtures'; import ghostConfig from '../../../../../core/shared/config'; import {createDatabaseAutomationsRepository} from '../../../../../core/server/services/automations/database-automations-repository'; import type {AutomatedEmailEvents, AutomationAction, AutomationsRepository, AutomationStepToRun} from '../../../../../core/server/services/automations/automations-repository'; -import {DATABASE_DATE_FORMAT, fromDatabaseDate, toDatabaseDate} from '../../../../../core/server/lib/db-date'; +import {fromDatabaseDate, toDatabaseDate} from '../../../../../core/server/lib/db-date'; const HOUR_MS = 60 * 60 * 1000; const FAKE_WAIT_HOURS_MULTIPLIER = 2500; @@ -1115,7 +1115,7 @@ describe('automations repository', function () { const step = await getStepByRunId(run.id); assert(step); - const readyAtMs = moment(step.ready_at, DATABASE_DATE_FORMAT).valueOf(); + const readyAtMs = fromDatabaseDate(step.ready_at).valueOf(); assert(readyAtMs >= beforeTrigger + (48 * FAKE_WAIT_HOURS_MULTIPLIER) - 999); assert(readyAtMs <= afterTrigger + (48 * FAKE_WAIT_HOURS_MULTIPLIER)); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27751ee72b9..a1dcf735235 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2717,6 +2717,9 @@ importers: '@types/lodash-es': specifier: 4.17.12 version: 4.17.12 + '@types/luxon': + specifier: 'catalog:' + version: 3.7.4 '@types/mime-types': specifier: 3.0.1 version: 3.0.1 From 14ce9f8573ff0b04f98e1b09fcd12eea228477e6 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Wed, 19 Aug 2026 12:03:34 +0100 Subject: [PATCH 4/4] Cleaned up seeder date handling around the shared database date helpers ref https://linear.app/ghost/issue/BER-3851 The seeders kept a utils/database-date module that, after the move to the shared helpers, only held a faker range helper under a misleading name. It now lives in utils/random as randomDateBetween, and the importers that still inlined faker.date.between with database-derived bounds use it too, so inverted ranges are clamped consistently instead of throwing. The shared fromDatabaseDate rejects null instead of returning the epoch, so the members-created-events importer now excludes draft posts from its attribution query rather than crashing on a public draft's null published_at. Per-batch timestamps in the email recipients importer are converted once per batch instead of once per generated row, and the SQLite-specific normalisation guards are gone now that the shared helper accepts every database representation. --- .../importers/automation-actions-importer.ts | 9 +++---- .../importers/automation-runs-importer.ts | 6 ++--- .../importers/comment-reports-importer.js | 5 ++-- .../importers/email-batches-importer.js | 6 ++--- .../importers/email-recipients-importer.js | 25 ++++++------------- .../members-click-events-importer.js | 4 +-- .../members-created-events-importer.js | 2 +- .../importers/members-feedback-importer.js | 7 +++--- .../members-status-events-importer.js | 4 +-- .../members-subscribe-events-importer.js | 5 ++-- .../importers/offer-redemptions-importer.js | 4 +-- .../data/seeders/utils/database-date.ts | 9 ------- .../core/server/data/seeders/utils/random.ts | 8 ++++++ .../data/seeders/data-generator.test.js | 4 +-- 14 files changed, 40 insertions(+), 58 deletions(-) delete mode 100644 ghost/core/core/server/data/seeders/utils/database-date.ts diff --git a/ghost/core/core/server/data/seeders/importers/automation-actions-importer.ts b/ghost/core/core/server/data/seeders/importers/automation-actions-importer.ts index 43f0a04a451..87774e9f694 100644 --- a/ghost/core/core/server/data/seeders/importers/automation-actions-importer.ts +++ b/ghost/core/core/server/data/seeders/importers/automation-actions-importer.ts @@ -1,8 +1,8 @@ -import {faker} from '@faker-js/faker'; import errors from '@tryghost/errors'; import type {Knex} from 'knex'; import {TableImporter} from './table-importer'; -import {fromDatabaseDate, toDatabaseDate} from '../../../lib/db-date'; +import {toDatabaseDate} from '../../../lib/db-date'; +import {randomDateBetween} from '../utils/random'; type Automation = { id: string; @@ -50,10 +50,7 @@ export class AutomationActionsImporter extends TableImporter new Date()) { timestamp = new Date(); @@ -188,9 +181,7 @@ class EmailRecipientsImporter extends TableImporter { let deliveredTime; if (status === emailStatus.opened) { - const startDate = fromDatabaseDate(this.batch.updated_at); - const endDate = timestamp; - deliveredTime = databaseDate.randomBetween(startDate, endDate); + deliveredTime = randomDateBetween(this.batchUpdatedAt, timestamp); } return { @@ -199,7 +190,7 @@ class EmailRecipientsImporter extends TableImporter { email_id: this.model.id, batch_id: this.batch.id, member_id: member.id, - processed_at: toDatabaseDate(this.batch.updated_at), + processed_at: this.batchProcessedAt, delivered_at: status === emailStatus.opened ? toDatabaseDate(deliveredTime) : status === emailStatus.delivered ? toDatabaseDate(timestamp) : null, opened_at: status === emailStatus.opened ? toDatabaseDate(timestamp) : null, failed_at: status === emailStatus.failed ? toDatabaseDate(timestamp) : null, diff --git a/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js index c788379bca0..c52905cbf43 100644 --- a/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js @@ -1,6 +1,6 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); -const {luck} = require('../utils/random'); +const {luck, randomDateBetween} = require('../utils/random'); const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class MembersClickEventsImporter extends TableImporter { @@ -52,7 +52,7 @@ class MembersClickEventsImporter extends TableImporter { const openedAt = fromDatabaseDate(this.model.opened_at); const laterOn = new Date(openedAt.getTime() + 1000 * 60 * 15); - const clickTime = faker.date.between({from: openedAt.getTime(), to: laterOn.getTime()}); //added getTime here because it threw random errors + const clickTime = randomDateBetween(openedAt, laterOn); return { id: this.fastFakeObjectId(), diff --git a/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js index df71172c67d..641a2ee3fee 100644 --- a/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js @@ -13,7 +13,7 @@ class MembersCreatedEventsImporter extends TableImporter { async import(quantity) { const members = await this.transaction.select('id', 'created_at').from('members'); - this.posts = await this.transaction.select('id', 'published_at', 'visibility', 'type', 'slug').from('posts').orderBy('published_at', 'desc'); + this.posts = await this.transaction.select('id', 'published_at', 'visibility', 'type', 'slug').from('posts').whereNotNull('published_at').orderBy('published_at', 'desc'); this.incomingRecommendations = await this.transaction.select('id', 'source', 'created_at').from('mentions'); await this.importForEach(members, quantity ? quantity / members.length : 1); diff --git a/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js b/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js index 0da06c044cc..9563b87812d 100644 --- a/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js @@ -1,6 +1,5 @@ const {TableImporter} = require('./table-importer'); -const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {luck, randomDateBetween} = require('../utils/random'); const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class MembersFeedbackImporter extends TableImporter { @@ -26,9 +25,9 @@ class MembersFeedbackImporter extends TableImporter { } const openedAt = fromDatabaseDate(this.model.opened_at); - const laterOn = fromDatabaseDate(this.model.opened_at); + const laterOn = new Date(openedAt); laterOn.setMinutes(laterOn.getMinutes() + 60); - const feedbackTime = databaseDate.randomBetween(openedAt, laterOn); + const feedbackTime = randomDateBetween(openedAt, laterOn); const postId = this.emails.find(email => email.id === this.model.email_id).post_id; return { diff --git a/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js index 7fad098b990..be1752ef98d 100644 --- a/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js @@ -1,5 +1,5 @@ const {TableImporter} = require('./table-importer'); -const databaseDate = require('../utils/database-date'); +const {randomDateBetween} = require('../utils/random'); const {toDatabaseDate} = require('../../../lib/db-date'); class MembersStatusEventsImporter extends TableImporter { @@ -40,7 +40,7 @@ class MembersStatusEventsImporter extends TableImporter { member_id: model.id, from_status: 'free', to_status: model.status, - created_at: toDatabaseDate(databaseDate.randomBetween(model.created_at, new Date())) + created_at: toDatabaseDate(randomDateBetween(model.created_at, new Date())) }); } } diff --git a/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js index c9cb9f6b409..57e3daf3185 100644 --- a/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js @@ -1,6 +1,5 @@ const {TableImporter} = require('./table-importer'); -const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {luck, randomDateBetween} = require('../utils/random'); const {toDatabaseDate} = require('../../../lib/db-date'); class MembersSubscribeEventsImporter extends TableImporter { @@ -51,7 +50,7 @@ class MembersSubscribeEventsImporter extends TableImporter { return null; } - const createdAt = toDatabaseDate(databaseDate.randomBetween(this.model.created_at, new Date())); + const createdAt = toDatabaseDate(randomDateBetween(this.model.created_at, new Date())); const newsletterId = this.newsletters[count % this.newsletters.length].id; return { diff --git a/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js b/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js index 9d9cc484206..ba16aee37a9 100644 --- a/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js +++ b/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js @@ -1,6 +1,7 @@ const {faker} = require('@faker-js/faker'); const errors = require('@tryghost/errors'); const {TableImporter} = require('./table-importer'); +const {randomDateBetween} = require('../utils/random'); const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class OfferRedemptionsImporter extends TableImporter { @@ -93,8 +94,7 @@ class OfferRedemptionsImporter extends TableImporter { candidateEarliest.valueOf(), subscriptionState.redemptionEndAt.valueOf() )); - const latest = subscriptionState.redemptionEndAt > earliest ? subscriptionState.redemptionEndAt : earliest; - const createdAt = latest.valueOf() === earliest.valueOf() ? earliest : faker.date.between({from: earliest, to: latest}); + const createdAt = randomDateBetween(earliest, subscriptionState.redemptionEndAt); subscriptionState.lastRedeemedAt = createdAt; diff --git a/ghost/core/core/server/data/seeders/utils/database-date.ts b/ghost/core/core/server/data/seeders/utils/database-date.ts deleted file mode 100644 index 7aa42e07585..00000000000 --- a/ghost/core/core/server/data/seeders/utils/database-date.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {faker} from '@faker-js/faker'; -import {fromDatabaseDate} from '../../../lib/db-date'; - -export function randomBetween(start: Date | string | number, end: Date | string | number): Date { - const earliest = fromDatabaseDate(start); - const latest = fromDatabaseDate(end); - - return latest > earliest ? faker.date.between({from: earliest, to: latest}) : earliest; -} diff --git a/ghost/core/core/server/data/seeders/utils/random.ts b/ghost/core/core/server/data/seeders/utils/random.ts index 357209a3524..0ba0a36de45 100644 --- a/ghost/core/core/server/data/seeders/utils/random.ts +++ b/ghost/core/core/server/data/seeders/utils/random.ts @@ -1,4 +1,5 @@ import {faker} from '@faker-js/faker'; +import {fromDatabaseDate, type DatabaseDate} from '../../../lib/db-date'; /** * Adds another degree of randomness into some decisions @@ -9,3 +10,10 @@ export const luck = (lowerThan: number): boolean => faker.number.int({ min: 1, max: 100 }) <= lowerThan; + +export function randomDateBetween(start: DatabaseDate, end: DatabaseDate): Date { + const earliest = fromDatabaseDate(start); + const latest = fromDatabaseDate(end); + + return latest > earliest ? faker.date.between({from: earliest, to: latest}) : earliest; +} diff --git a/ghost/core/test/unit/server/data/seeders/data-generator.test.js b/ghost/core/test/unit/server/data/seeders/data-generator.test.js index bbe072bab5e..af6b9839a19 100644 --- a/ghost/core/test/unit/server/data/seeders/data-generator.test.js +++ b/ghost/core/test/unit/server/data/seeders/data-generator.test.js @@ -11,7 +11,7 @@ const AutomationsImporter = importers.find(i => i.table === 'automations'); const AutomationRunStepsImporter = importers.find(i => i.table === 'automation_run_steps'); const generateEvents = require('../../../../../core/server/data/seeders/utils/event-generator'); -const databaseDate = require('../../../../../core/server/data/seeders/utils/database-date'); +const {randomDateBetween} = require('../../../../../core/server/data/seeders/utils/random'); const DataGenerator = require('../../../../../core/server/data/seeders/data-generator'); @@ -483,7 +483,7 @@ describe('Events Generator', function () { const startDate = new Date('2026-03-26T11:50:00.000Z'); const endDate = new Date('2026-03-26T10:00:00.000Z'); - assert.equal(databaseDate.randomBetween(startDate, endDate).toISOString(), startDate.toISOString()); + assert.equal(randomDateBetween(startDate, endDate).toISOString(), startDate.toISOString()); }); it('Generates a set of timestamps which meet the criteria', function () {