From 403c141a35c36b8c53ca227115f33fca1067a039 Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Thu, 23 Jul 2026 12:24:39 +0100 Subject: [PATCH 1/9] Added a lazy singleton service facade no ref --- ghost/core/core/shared/lazy-singleton.ts | 34 +++++++++++++ .../test/unit/shared/lazy-singleton.test.ts | 51 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 ghost/core/core/shared/lazy-singleton.ts create mode 100644 ghost/core/test/unit/shared/lazy-singleton.test.ts diff --git a/ghost/core/core/shared/lazy-singleton.ts b/ghost/core/core/shared/lazy-singleton.ts new file mode 100644 index 00000000000..0e7c10b97ac --- /dev/null +++ b/ghost/core/core/shared/lazy-singleton.ts @@ -0,0 +1,34 @@ +import {InternalServerError} from '@tryghost/errors'; + +/** + * Exposes a stable facade for a service that is constructed during boot. + * + * The facade resolves the current service instance for every property access, + * allowing require-shaped consumers to import it before the service is + * initialized without exposing an optional value. + */ +export function lazySingleton(name: string, getInstance: () => T | undefined): T { + const resolve = (): T => { + const instance = getInstance(); + + if (!instance) { + throw new InternalServerError({ + message: `${name} must be initialized before use` + }); + } + + return instance; + }; + + return new Proxy({} as T, { + get(_target, property) { + const instance = resolve(); + const value = Reflect.get(instance, property, instance); + + return typeof value === 'function' ? value.bind(instance) : value; + }, + set(_target, property, value) { + return Reflect.set(resolve(), property, value); + } + }); +} diff --git a/ghost/core/test/unit/shared/lazy-singleton.test.ts b/ghost/core/test/unit/shared/lazy-singleton.test.ts new file mode 100644 index 00000000000..300ef5f67a3 --- /dev/null +++ b/ghost/core/test/unit/shared/lazy-singleton.test.ts @@ -0,0 +1,51 @@ +import {describe, expect, it} from 'vitest'; +import {lazySingleton} from '../../../core/shared/lazy-singleton'; + +describe('lazySingleton', function () { + it('returns a stable facade', function () { + const facade = lazySingleton('ExampleService', () => ({value: 1})); + + expect(facade).toBe(facade); + }); + + it('throws an actionable internal error when read before initialization', function () { + const facade = lazySingleton<{value: number}>('ExampleService', () => undefined); + + expect(() => facade.value).toThrow('ExampleService must be initialized before use'); + }); + + it('forwards property reads and writes to the current instance', function () { + let instance = {value: 1}; + const facade = lazySingleton('ExampleService', () => instance); + + expect(facade.value).toBe(1); + + facade.value = 2; + expect(instance.value).toBe(2); + + instance = {value: 3}; + expect(facade.value).toBe(3); + }); + + it('preserves method binding when a method is extracted', function () { + const instance = { + value: 1, + increment() { + this.value += 1; + return this.value; + } + }; + const facade = lazySingleton('ExampleService', () => instance); + + const increment = facade.increment; + + expect(increment()).toBe(2); + expect(instance.value).toBe(2); + }); + + it('forwards null values returned by an initialized service', function () { + const facade = lazySingleton('ExampleService', () => ({value: null as null | string})); + + expect(facade.value).toBeNull(); + }); +}); From 8d287df61625cb7049d4127fb333f90bc3cac6d4 Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Thu, 23 Jul 2026 12:25:58 +0100 Subject: [PATCH 2/9] Changed ping services to functional composition roots no ref --- ghost/core/core/boot.js | 4 +- .../server/services/explore-ping/index.ts | 48 +++++++++--------- .../server/services/indexnow-ping/index.ts | 50 ++++++++----------- .../core/server/services/slack-ping/index.ts | 50 ++++++++----------- 4 files changed, 71 insertions(+), 81 deletions(-) diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index b741f0392d5..068c653ef05 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -326,8 +326,8 @@ async function initServices({ghostServer, config, prometheusClient}) { const members = require('./server/services/members'); const tiers = require('./server/services/tiers'); const permissions = require('./server/services/permissions'); - const indexnow = require('./server/services/indexnow-ping').default; - const slack = require('./server/services/slack-ping').default; + const indexnow = require('./server/services/indexnow-ping'); + const slack = require('./server/services/slack-ping'); const webhooks = require('./server/services/webhooks'); const postScheduling = require('./server/services/post-scheduling').default; const comments = require('./server/services/comments'); diff --git a/ghost/core/core/server/services/explore-ping/index.ts b/ghost/core/core/server/services/explore-ping/index.ts index 53a146b5347..bdbe46297cd 100644 --- a/ghost/core/core/server/services/explore-ping/index.ts +++ b/ghost/core/core/server/services/explore-ping/index.ts @@ -1,17 +1,30 @@ import {ExplorePingService} from './explore-ping-service'; -const config = require('../../../shared/config'); -const logging = require('@tryghost/logging'); -const ghostVersion = require('@tryghost/version'); -const request = require('@tryghost/request'); -const settingsCache = require('../../../shared/settings-cache'); -const posts = require('../posts/posts-service-instance'); -const members = require('../members'); -const statsService = require('../stats'); +let service: ExplorePingService | undefined; -// Export the creation function for testing -export function createService(): ExplorePingService { - return new ExplorePingService({ +export async function init(): Promise { + if (service) { + return; + } + + const config = require('../../../shared/config'); + + // The explore ping is a background "phone home" request. It should not run + // in the test environment (cf. the update-check service, which gates on the + // same environments), where there is no explore URL configured. + if (!config.isProductionOrDevelopment()) { + return; + } + + const logging = require('@tryghost/logging'); + const ghostVersion = require('@tryghost/version'); + const request = require('@tryghost/request'); + const settingsCache = require('../../../shared/settings-cache'); + const posts = require('../posts/posts-service-instance'); + const members = require('../members'); + const statsService = require('../stats'); + + service = new ExplorePingService({ settingsCache, config, logging, @@ -21,20 +34,9 @@ export function createService(): ExplorePingService { members, statsService }); -} - -export async function init(): Promise { - // The explore ping is a background "phone home" request. It should not run - // in the test environment (cf. the update-check service, which gates on the - // same environments), where there is no explore URL configured. - if (!config.isProductionOrDevelopment()) { - return; - } - - const explorePingService = createService(); // The final intention is to have this run on a schedule // For the initial version, we'll just ping when the server starts // Without waiting for the response - explorePingService.ping(); + service.ping(); } diff --git a/ghost/core/core/server/services/indexnow-ping/index.ts b/ghost/core/core/server/services/indexnow-ping/index.ts index 092f9d8132e..f942a241554 100644 --- a/ghost/core/core/server/services/indexnow-ping/index.ts +++ b/ghost/core/core/server/services/indexnow-ping/index.ts @@ -1,35 +1,29 @@ import {IndexNowPingService} from './indexnow-ping-service'; -class IndexNowPingServiceWrapper { - service?: IndexNowPingService; +let service: IndexNowPingService | undefined; - init(): void { - if (this.service) { - // Already done - return; - } +export function init(): void { + if (service) { + return; + } - // Wire up all the dependencies - const settingsCache = require('../../../shared/settings-cache'); - const config = require('../../../shared/config'); - const urlService = require('../url'); - const urlUtils = require('../../../shared/url-utils').default; - const request = require('@tryghost/request'); - const logging = require('@tryghost/logging'); - const events = require('../../lib/common/events'); + const settingsCache = require('../../../shared/settings-cache'); + const config = require('../../../shared/config'); + const urlService = require('../url'); + const urlUtils = require('../../../shared/url-utils').default; + const request = require('@tryghost/request'); + const logging = require('@tryghost/logging'); + const events = require('../../lib/common/events'); - this.service = new IndexNowPingService({ - settingsCache, - config, - urlService, - urlUtils, - request, - logging, - events - }); + service = new IndexNowPingService({ + settingsCache, + config, + urlService, + urlUtils, + request, + logging, + events + }); - this.service.subscribeEvents(); - } + service.subscribeEvents(); } - -export default new IndexNowPingServiceWrapper(); diff --git a/ghost/core/core/server/services/slack-ping/index.ts b/ghost/core/core/server/services/slack-ping/index.ts index 1ec1176b6f9..0f30f850d4c 100644 --- a/ghost/core/core/server/services/slack-ping/index.ts +++ b/ghost/core/core/server/services/slack-ping/index.ts @@ -1,35 +1,29 @@ import {SlackPingService} from './slack-ping-service'; -class SlackPingServiceWrapper { - service?: SlackPingService; +let service: SlackPingService | undefined; - init(): void { - if (this.service) { - // Already done - return; - } +export function init(): void { + if (service) { + return; + } - // Wire up all the dependencies - const {blogIcon} = require('../../lib/image'); - const events = require('../../lib/common/events'); - const logging = require('@tryghost/logging'); - const request = require('../../lib/request-external'); - const settingsCache = require('../../../shared/settings-cache'); - const urlService = require('../url'); - const urlUtils = require('../../../shared/url-utils').default; + const {blogIcon} = require('../../lib/image'); + const events = require('../../lib/common/events'); + const logging = require('@tryghost/logging'); + const request = require('../../lib/request-external'); + const settingsCache = require('../../../shared/settings-cache'); + const urlService = require('../url'); + const urlUtils = require('../../../shared/url-utils').default; - this.service = new SlackPingService({ - blogIcon, - events, - logging, - request, - settingsCache, - urlService, - urlUtils - }); + service = new SlackPingService({ + blogIcon, + events, + logging, + request, + settingsCache, + urlService, + urlUtils + }); - this.service.subscribeEvents(); - } + service.subscribeEvents(); } - -export default new SlackPingServiceWrapper(); From ee280dbdc11a2e5b0d4e1a1666b8b4d2d066fdb4 Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Thu, 23 Jul 2026 12:29:14 +0100 Subject: [PATCH 3/9] Changed consumed services to stable lazy facades no ref --- ghost/core/core/boot.js | 2 ++ .../core/server/api/endpoints/gift-links.ts | 8 ++--- .../core/server/api/endpoints/tinybird.js | 5 ++- .../api/endpoints/utils/gift-link-access.ts | 2 +- .../core/server/services/gift-links/index.ts | 9 +++-- .../server/services/stats/stats-service.js | 6 ++-- .../core/server/services/tinybird/index.js | 34 ++++++++++++++++++- .../tinybird/tinybird-service-wrapper.js | 32 ----------------- ghost/core/core/shared/lazy-singleton.ts | 12 +++++++ .../endpoints/utils/gift-link-access.test.ts | 2 +- .../server/services/tinybird/index.test.js | 25 ++++++++++++++ .../test/unit/shared/lazy-singleton.test.ts | 10 ++++-- 12 files changed, 96 insertions(+), 51 deletions(-) delete mode 100644 ghost/core/core/server/services/tinybird/tinybird-service-wrapper.js create mode 100644 ghost/core/test/unit/server/services/tinybird/index.test.js diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index 068c653ef05..a9b6d74d126 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -350,6 +350,7 @@ async function initServices({ghostServer, config, prometheusClient}) { const recommendationsService = require('./server/services/recommendations'); const emailAddressService = require('./server/services/email-address'); const statsService = require('./server/services/stats'); + const tinybird = require('./server/services/tinybird'); const explorePingService = require('./server/services/explore-ping'); const domainEvents = require('@tryghost/domain-events'); const automations = require('./server/services/automations'); @@ -406,6 +407,7 @@ async function initServices({ghostServer, config, prometheusClient}) { mediaInliner.init(), donationService.init(), recommendationsService.init(), + tinybird.init(), statsService.init(), explorePingService.init(), giftService.init({ diff --git a/ghost/core/core/server/api/endpoints/gift-links.ts b/ghost/core/core/server/api/endpoints/gift-links.ts index 59d42571f22..237086b1574 100644 --- a/ghost/core/core/server/api/endpoints/gift-links.ts +++ b/ghost/core/core/server/api/endpoints/gift-links.ts @@ -40,7 +40,7 @@ const controller = { return assertCanEditAndGift(frame); }, query(frame: Frame) { - return service!.getPost(frame.options.id); + return service.getPost(frame.options.id); } }, @@ -53,7 +53,7 @@ const controller = { return assertCanEditAndGift(frame); }, query(frame: Frame) { - return service!.ensure(requestContextFromFrame(frame), frame.options.id); + return service.ensure(requestContextFromFrame(frame), frame.options.id); } }, @@ -66,7 +66,7 @@ const controller = { return assertCanEditAndGift(frame); }, query(frame: Frame) { - return service!.create(requestContextFromFrame(frame), frame.options.id); + return service.create(requestContextFromFrame(frame), frame.options.id); } }, @@ -77,7 +77,7 @@ const controller = { return permissionsService.canThis(frame.options.context).removeAll.gift_link(); }, async query(frame: Frame) { - const count = await service!.removeAll(requestContextFromFrame(frame)); + const count = await service.removeAll(requestContextFromFrame(frame)); return {count}; } } diff --git a/ghost/core/core/server/api/endpoints/tinybird.js b/ghost/core/core/server/api/endpoints/tinybird.js index 4404c4a1fd7..3d108872bd2 100644 --- a/ghost/core/core/server/api/endpoints/tinybird.js +++ b/ghost/core/core/server/api/endpoints/tinybird.js @@ -1,4 +1,4 @@ -const TinybirdServiceWrapper = require('../../services/tinybird'); +const tinybird = require('../../services/tinybird'); /** @type {import('@tryghost/api-framework').Controller} */ const controller = { @@ -13,8 +13,7 @@ const controller = { method: 'browse' }, async query() { - TinybirdServiceWrapper.init(); - const tokenData = TinybirdServiceWrapper.instance?.getToken() ?? null; + const tokenData = tinybird.service.getToken(); if (tokenData?.exp) { return { diff --git a/ghost/core/core/server/api/endpoints/utils/gift-link-access.ts b/ghost/core/core/server/api/endpoints/utils/gift-link-access.ts index 1b9cfa80910..c7a0a7450e9 100644 --- a/ghost/core/core/server/api/endpoints/utils/gift-link-access.ts +++ b/ghost/core/core/server/api/endpoints/utils/gift-link-access.ts @@ -38,7 +38,7 @@ export async function generateGiftKeyData(frame: Frame): Promise<{present: true; return undefined; } - const post = await giftLinksService!.getPostByToken(token); + const post = await giftLinksService.getPostByToken(token); const postId = post ? post.id : null; frame.giftLinkPostId = postId; diff --git a/ghost/core/core/server/services/gift-links/index.ts b/ghost/core/core/server/services/gift-links/index.ts index a0e1c059cae..02d8645d419 100644 --- a/ghost/core/core/server/services/gift-links/index.ts +++ b/ghost/core/core/server/services/gift-links/index.ts @@ -1,13 +1,16 @@ import {GiftLinksService} from './service'; import {recordGiftLinkAction, type RecordGiftLinkAction} from './actions'; +import {lazySingleton} from '../../../shared/lazy-singleton'; export type {RequestContext} from './actions'; // Constructed by init() at boot, not at import: knex is only available once the DB has connected. -export let service: GiftLinksService | undefined; +let instance: GiftLinksService | undefined; + +export const service = lazySingleton('GiftLinksService', () => instance); export function init(): void { - if (service) { + if (instance) { return; } @@ -16,5 +19,5 @@ export function init(): void { const recordAction: RecordGiftLinkAction = ({context, verb, subject}) => recordGiftLinkAction({Action: models.Action, context, verb, subject}); - service = new GiftLinksService({knex, recordAction}); + instance = new GiftLinksService({knex, recordAction}); } diff --git a/ghost/core/core/server/services/stats/stats-service.js b/ghost/core/core/server/services/stats/stats-service.js index 3161df729a3..3697fc74be3 100644 --- a/ghost/core/core/server/services/stats/stats-service.js +++ b/ghost/core/core/server/services/stats/stats-service.js @@ -248,13 +248,13 @@ class StatsService { if (settingsCache.get('web_analytics_enabled')) { // TODO: move the tinybird client to the tinybird service - const TinybirdServiceWrapper = require('../tinybird'); - TinybirdServiceWrapper.init(); + const tinybird = require('../tinybird'); + tinybird.init(); tinybirdClient = require('./utils/tinybird').create({ config, request, settingsCache, - tinybirdService: TinybirdServiceWrapper.instance + tinybirdService: tinybird.service }); } diff --git a/ghost/core/core/server/services/tinybird/index.js b/ghost/core/core/server/services/tinybird/index.js index ff110775777..23e375beee1 100644 --- a/ghost/core/core/server/services/tinybird/index.js +++ b/ghost/core/core/server/services/tinybird/index.js @@ -1 +1,33 @@ -module.exports = require('./tinybird-service-wrapper'); +const TinybirdService = require('./tinybird-service'); +const {lazySingleton} = require('../../../shared/lazy-singleton'); + +let instance; + +const service = lazySingleton('TinybirdService', () => instance); + +function init() { + if (instance) { + return; + } + + const config = require('../../../shared/config'); + const settingsCache = require('../../../shared/settings-cache'); + const logging = require('@tryghost/logging'); + + const tinybirdConfig = config.get('tinybird'); + const siteUuid = settingsCache.get('site_uuid'); + + if (!tinybirdConfig || !siteUuid) { + logging.warn('Tinybird service not configured'); + } + + instance = new TinybirdService({ + tinybirdConfig, + siteUuid + }); +} + +module.exports = { + init, + service +}; diff --git a/ghost/core/core/server/services/tinybird/tinybird-service-wrapper.js b/ghost/core/core/server/services/tinybird/tinybird-service-wrapper.js deleted file mode 100644 index 3dc76b93ec3..00000000000 --- a/ghost/core/core/server/services/tinybird/tinybird-service-wrapper.js +++ /dev/null @@ -1,32 +0,0 @@ -const TinybirdService = require('./tinybird-service'); - -module.exports = class TinybirdServiceWrapper { - /** @type TinybirdService */ - static instance; - - static init() { - const config = require('../../../shared/config'); - const settingsCache = require('../../../shared/settings-cache'); - const logging = require('@tryghost/logging'); - - const tinybirdConfig = config.get('tinybird'); - const siteUuid = settingsCache.get('site_uuid'); - - if (!tinybirdConfig || !siteUuid) { - logging.warn('Tinybird service not configured'); - TinybirdServiceWrapper.instance = null; - return; - } - - // Create instance with valid config - TinybirdServiceWrapper.instance = new TinybirdService({ - tinybirdConfig, - siteUuid - }); - } - - // Reset the instance for testing - static reset() { - TinybirdServiceWrapper.instance = null; - } -}; diff --git a/ghost/core/core/shared/lazy-singleton.ts b/ghost/core/core/shared/lazy-singleton.ts index 0e7c10b97ac..054a2af7dee 100644 --- a/ghost/core/core/shared/lazy-singleton.ts +++ b/ghost/core/core/shared/lazy-singleton.ts @@ -29,6 +29,18 @@ export function lazySingleton(name: string, getInstance: () => }, set(_target, property, value) { return Reflect.set(resolve(), property, value); + }, + has(_target, property) { + return Reflect.has(resolve(), property); + }, + defineProperty(_target, property, attributes) { + return Reflect.defineProperty(resolve(), property, attributes); + }, + deleteProperty(_target, property) { + return Reflect.deleteProperty(resolve(), property); + }, + getPrototypeOf() { + return Reflect.getPrototypeOf(resolve()); } }); } diff --git a/ghost/core/test/unit/api/endpoints/utils/gift-link-access.test.ts b/ghost/core/test/unit/api/endpoints/utils/gift-link-access.test.ts index cc8f6e793b3..d7af1b92247 100644 --- a/ghost/core/test/unit/api/endpoints/utils/gift-link-access.test.ts +++ b/ghost/core/test/unit/api/endpoints/utils/gift-link-access.test.ts @@ -22,7 +22,7 @@ describe('Gift link access', function () { // The service singleton is normally wired at boot; the stub replaces // its only query so no DB is touched. giftLinksService.init(); - getPostByTokenStub = sinon.stub(giftLinksService.service!, 'getPostByToken'); + getPostByTokenStub = sinon.stub(giftLinksService.service, 'getPostByToken'); sinon.stub(Product, 'findAll').resolves([{ get: sinon.stub().returns('silver') diff --git a/ghost/core/test/unit/server/services/tinybird/index.test.js b/ghost/core/test/unit/server/services/tinybird/index.test.js new file mode 100644 index 00000000000..f506c66490b --- /dev/null +++ b/ghost/core/test/unit/server/services/tinybird/index.test.js @@ -0,0 +1,25 @@ +const assert = require('node:assert/strict'); + +describe('Tinybird service composition root', function () { + let tinybird; + + beforeEach(function () { + const modulePath = require.resolve('../../../../../core/server/services/tinybird'); + delete require.cache[modulePath]; + tinybird = require(modulePath); + }); + + it('fails loudly when the service is used before initialization', function () { + assert.throws( + () => tinybird.service.getToken(), + /TinybirdService must be initialized before use/ + ); + }); + + it('is idempotent and returns null after unconfigured initialization', function () { + tinybird.init(); + tinybird.init(); + + assert.equal(tinybird.service.getToken(), null); + }); +}); diff --git a/ghost/core/test/unit/shared/lazy-singleton.test.ts b/ghost/core/test/unit/shared/lazy-singleton.test.ts index 300ef5f67a3..630e36c72ca 100644 --- a/ghost/core/test/unit/shared/lazy-singleton.test.ts +++ b/ghost/core/test/unit/shared/lazy-singleton.test.ts @@ -28,19 +28,23 @@ describe('lazySingleton', function () { }); it('preserves method binding when a method is extracted', function () { - const instance = { - value: 1, + class ExampleService { + value = 1; + increment() { this.value += 1; return this.value; } - }; + } + + const instance = new ExampleService(); const facade = lazySingleton('ExampleService', () => instance); const increment = facade.increment; expect(increment()).toBe(2); expect(instance.value).toBe(2); + expect(facade).toBeInstanceOf(ExampleService); }); it('forwards null values returned by an initialized service', function () { From 57156d5d45f34eba67fefc5e42fcf05523a93a5a Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Thu, 23 Jul 2026 12:31:13 +0100 Subject: [PATCH 4/9] Changed boot services to explicit initialization no ref --- ghost/core/core/boot.js | 6 ++-- .../server/api/endpoints/announcements.js | 2 +- .../announcement-bar-service/index.js | 33 ++++++++++++++----- .../donations/donation-service-wrapper.js | 19 ----------- .../core/server/services/donations/index.js | 33 +++++++++++++++++-- .../core/server/services/stripe/service.js | 4 +-- .../server/services/stripe/stripe-service.js | 6 ++-- .../services/announcement-bar/index.test.js | 28 ++++++++++++++++ .../server/services/donations/index.test.js | 25 ++++++++++++++ 9 files changed, 118 insertions(+), 38 deletions(-) delete mode 100644 ghost/core/core/server/services/donations/donation-service-wrapper.js create mode 100644 ghost/core/test/unit/server/services/announcement-bar/index.test.js create mode 100644 ghost/core/test/unit/server/services/donations/index.test.js diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index a9b6d74d126..3d2af0e17c8 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -322,6 +322,8 @@ async function initServices({ghostServer, config, prometheusClient}) { debug('Begin: Services'); const identityTokens = require('./server/services/identity-tokens'); + const donationService = require('./server/services/donations'); + donationService.init(); const stripe = require('./server/services/stripe'); const members = require('./server/services/members'); const tiers = require('./server/services/tiers'); @@ -344,7 +346,7 @@ async function initServices({ghostServer, config, prometheusClient}) { const postsPublic = require('./server/services/posts-public'); const slackNotifications = require('./server/services/slack-notifications'); const mediaInliner = require('./server/services/media-inliner'); - const donationService = require('./server/services/donations'); + const announcementBarService = require('./server/services/announcement-bar-service'); const giftService = require('./server/services/gifts'); const machinePaymentsService = require('./server/services/machine-payments'); const recommendationsService = require('./server/services/recommendations'); @@ -405,7 +407,7 @@ async function initServices({ghostServer, config, prometheusClient}) { emailSuppressionList.init(), slackNotifications.init(), mediaInliner.init(), - donationService.init(), + announcementBarService.init(), recommendationsService.init(), tinybird.init(), statsService.init(), diff --git a/ghost/core/core/server/api/endpoints/announcements.js b/ghost/core/core/server/api/endpoints/announcements.js index 2a6bbdfd85a..14576b66b17 100644 --- a/ghost/core/core/server/api/endpoints/announcements.js +++ b/ghost/core/core/server/api/endpoints/announcements.js @@ -1,4 +1,4 @@ -const announcementBarSettings = require('../../services/announcement-bar-service'); +const announcementBarSettings = require('../../services/announcement-bar-service').service; /** @type {import('@tryghost/api-framework').Controller} */ const controller = { diff --git a/ghost/core/core/server/services/announcement-bar-service/index.js b/ghost/core/core/server/services/announcement-bar-service/index.js index 76c1bdf3ea9..0b192c8e758 100644 --- a/ghost/core/core/server/services/announcement-bar-service/index.js +++ b/ghost/core/core/server/services/announcement-bar-service/index.js @@ -1,12 +1,27 @@ -const settingsCache = require('../../../shared/settings-cache'); const AnnouncementBarSettings = require('./announcement-bar-settings'); +const {lazySingleton} = require('../../../shared/lazy-singleton'); -const announcementBarService = new AnnouncementBarSettings({ - getAnnouncementSettings: () => ({ - announcement: settingsCache.get('announcement_content'), - announcement_background: settingsCache.get('announcement_background'), - announcement_visibility: settingsCache.get('announcement_visibility') - }) -}); +let instance; -module.exports = announcementBarService; +const service = lazySingleton('AnnouncementBarSettings', () => instance); + +function init() { + if (instance) { + return; + } + + const settingsCache = require('../../../shared/settings-cache'); + + instance = new AnnouncementBarSettings({ + getAnnouncementSettings: () => ({ + announcement: settingsCache.get('announcement_content'), + announcement_background: settingsCache.get('announcement_background'), + announcement_visibility: settingsCache.get('announcement_visibility') + }) + }); +} + +module.exports = { + init, + service +}; diff --git a/ghost/core/core/server/services/donations/donation-service-wrapper.js b/ghost/core/core/server/services/donations/donation-service-wrapper.js deleted file mode 100644 index c2480d8dfaf..00000000000 --- a/ghost/core/core/server/services/donations/donation-service-wrapper.js +++ /dev/null @@ -1,19 +0,0 @@ -const {DonationPaymentEvent: DonationPaymentEventModel} = require('../../models'); - -class DonationServiceWrapper { - repository; - - init() { - if (this.repository) { - return; - } - - const {DonationBookshelfRepository} = require('./donation-bookshelf-repository'); - - this.repository = new DonationBookshelfRepository({ - DonationPaymentEventModel - }); - } -} - -module.exports = DonationServiceWrapper; diff --git a/ghost/core/core/server/services/donations/index.js b/ghost/core/core/server/services/donations/index.js index 35f99f93e3f..d51eef0beef 100644 --- a/ghost/core/core/server/services/donations/index.js +++ b/ghost/core/core/server/services/donations/index.js @@ -1,3 +1,32 @@ -const DonationServiceWrapper = require('./donation-service-wrapper'); +let repository; -module.exports = new DonationServiceWrapper(); +function init() { + if (repository) { + return repository; + } + + const {DonationPaymentEvent: DonationPaymentEventModel} = require('../../models'); + const {DonationBookshelfRepository} = require('./donation-bookshelf-repository'); + + repository = new DonationBookshelfRepository({ + DonationPaymentEventModel + }); + + return repository; +} + +function getRepository() { + if (!repository) { + const {InternalServerError} = require('@tryghost/errors'); + throw new InternalServerError({ + message: 'Donation repository must be initialized before use' + }); + } + + return repository; +} + +module.exports = { + init, + getRepository +}; diff --git a/ghost/core/core/server/services/stripe/service.js b/ghost/core/core/server/services/stripe/service.js index a7093447cf4..a7f42f1dbe2 100644 --- a/ghost/core/core/server/services/stripe/service.js +++ b/ghost/core/core/server/services/stripe/service.js @@ -9,7 +9,7 @@ const events = require('../../lib/common/events'); const models = require('../../models'); const {getConfig} = require('./config'); const settingsHelpers = require('../settings-helpers'); -const donationService = require('../donations'); +const donationRepository = require('../donations').getRepository(); const giftService = require('../gifts'); const staffService = require('../staff'); const labs = require('../../../shared/labs'); @@ -60,7 +60,7 @@ module.exports = new StripeService({ }]); } }, - donationService, + donationRepository, giftService, staffService, settingsCache diff --git a/ghost/core/core/server/services/stripe/stripe-service.js b/ghost/core/core/server/services/stripe/stripe-service.js index 2314554d6cc..ec1f0921639 100644 --- a/ghost/core/core/server/services/stripe/stripe-service.js +++ b/ghost/core/core/server/services/stripe/stripe-service.js @@ -37,7 +37,7 @@ module.exports = class StripeService { * @param {object} deps * @param {*} deps.labs * @param {*} deps.membersService - * @param {*} deps.donationService + * @param {*} deps.donationRepository * @param {*} deps.giftService * @param {*} deps.staffService * @param {import('./webhook-manager').StripeWebhook} deps.StripeWebhook @@ -54,7 +54,7 @@ module.exports = class StripeService { constructor({ labs, membersService, - donationService, + donationRepository, giftService, staffService, StripeWebhook, @@ -111,7 +111,7 @@ module.exports = class StripeService { return membersService.api.events; }, get donationRepository(){ - return donationService.repository; + return donationRepository; }, get giftService(){ return giftService.service; diff --git a/ghost/core/test/unit/server/services/announcement-bar/index.test.js b/ghost/core/test/unit/server/services/announcement-bar/index.test.js new file mode 100644 index 00000000000..0ba42dfab5b --- /dev/null +++ b/ghost/core/test/unit/server/services/announcement-bar/index.test.js @@ -0,0 +1,28 @@ +const assert = require('node:assert/strict'); + +describe('Announcement bar composition root', function () { + let announcementBar; + + beforeEach(function () { + const modulePath = require.resolve('../../../../../core/server/services/announcement-bar-service'); + delete require.cache[modulePath]; + announcementBar = require(modulePath); + }); + + it('fails loudly when the service is used before initialization', function () { + assert.throws( + () => announcementBar.service.getAnnouncementSettings(), + /AnnouncementBarSettings must be initialized before use/ + ); + }); + + it('initializes idempotently', function () { + announcementBar.init(); + const firstService = announcementBar.service; + + announcementBar.init(); + + assert.equal(announcementBar.service, firstService); + assert.doesNotThrow(() => announcementBar.service.getAnnouncementSettings()); + }); +}); diff --git a/ghost/core/test/unit/server/services/donations/index.test.js b/ghost/core/test/unit/server/services/donations/index.test.js new file mode 100644 index 00000000000..6ef3473e773 --- /dev/null +++ b/ghost/core/test/unit/server/services/donations/index.test.js @@ -0,0 +1,25 @@ +const assert = require('node:assert/strict'); + +describe('Donations composition root', function () { + let donations; + + beforeEach(function () { + const modulePath = require.resolve('../../../../../core/server/services/donations'); + delete require.cache[modulePath]; + donations = require(modulePath); + }); + + it('fails loudly when the repository is used before initialization', function () { + assert.throws( + () => donations.getRepository(), + /Donation repository must be initialized before use/ + ); + }); + + it('returns the same repository from repeated initialization', function () { + const repository = donations.init(); + + assert.equal(donations.init(), repository); + assert.equal(donations.getRepository(), repository); + }); +}); From cc38a7d6192300d52422264f462a74e12ef212dd Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Thu, 23 Jul 2026 12:33:45 +0100 Subject: [PATCH 5/9] Changed Posts to use one initialized service instance no ref --- ghost/core/core/boot.js | 5 +++- ghost/core/core/server/api/endpoints/pages.js | 3 +- .../core/server/api/endpoints/posts-public.js | 3 +- ghost/core/core/server/api/endpoints/posts.js | 3 +- .../api/endpoints/search-index-public.js | 3 +- .../core/server/api/endpoints/search-index.js | 3 +- .../utils/serializers/output/mappers/posts.js | 3 +- ghost/core/core/server/lib/lexical.js | 3 +- .../server/services/explore-ping/index.ts | 4 +-- .../services/posts/posts-service-instance.js | 28 +++++++++++++------ .../api/endpoints/search-index-public.test.js | 5 ++-- .../unit/api/endpoints/search-index.test.js | 5 ++-- .../posts/posts-service-instance.test.js | 25 +++++++++++++++++ 13 files changed, 63 insertions(+), 30 deletions(-) create mode 100644 ghost/core/test/unit/server/services/posts/posts-service-instance.test.js diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index 3d2af0e17c8..dc07c8cec4e 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -344,6 +344,7 @@ async function initServices({ghostServer, config, prometheusClient}) { const mentionsService = require('./server/services/mentions'); const tagsPublic = require('./server/services/tags-public'); const postsPublic = require('./server/services/posts-public'); + const postsService = require('./server/services/posts/posts-service-instance'); const slackNotifications = require('./server/services/slack-notifications'); const mediaInliner = require('./server/services/media-inliner'); const announcementBarService = require('./server/services/announcement-bar-service'); @@ -411,7 +412,6 @@ async function initServices({ghostServer, config, prometheusClient}) { recommendationsService.init(), tinybird.init(), statsService.init(), - explorePingService.init(), giftService.init({ apiUrl, schedulerAdapter, @@ -427,6 +427,9 @@ async function initServices({ghostServer, config, prometheusClient}) { }) ]); + postsService.init(); + await explorePingService.init(); + if (schedulerAdapter.rescheduleOnBoot) { await postScheduling.rescheduleAll(); } diff --git a/ghost/core/core/server/api/endpoints/pages.js b/ghost/core/core/server/api/endpoints/pages.js index 2428d24112a..40237c5bb52 100644 --- a/ghost/core/core/server/api/endpoints/pages.js +++ b/ghost/core/core/server/api/endpoints/pages.js @@ -1,7 +1,7 @@ const models = require('../../models'); const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); -const getPostServiceInstance = require('../../services/posts/posts-service-instance'); +const postsService = require('../../services/posts/posts-service-instance').service; const {rejectAdminApiRestrictedFieldsTransformer} = require('./utils/api-filter-utils'); const ALLOWED_INCLUDES = ['tags', 'authors', 'authors.roles', 'tiers', 'count.signups', 'count.paid_conversions', 'post_revisions', 'post_revisions.author']; const UNSAFE_ATTRS = ['status', 'authors', 'visibility']; @@ -10,7 +10,6 @@ const messages = { pageNotFound: 'Page not found.' }; -const postsService = getPostServiceInstance(); /** @type {import('@tryghost/api-framework').Controller} */ const controller = { diff --git a/ghost/core/core/server/api/endpoints/posts-public.js b/ghost/core/core/server/api/endpoints/posts-public.js index 96129307e2c..cf151647c2c 100644 --- a/ghost/core/core/server/api/endpoints/posts-public.js +++ b/ghost/core/core/server/api/endpoints/posts-public.js @@ -2,8 +2,7 @@ const models = require('../../models'); const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); const postsPublicService = require('../../services/posts-public'); -const getPostServiceInstance = require('../../services/posts/posts-service-instance'); -const postsService = getPostServiceInstance(); +const postsService = require('../../services/posts/posts-service-instance').service; const {rejectContentApiRestrictedFieldsTransformer} = require('./utils/api-filter-utils'); const {generateGiftKeyData, applyGiftAccess} = require('./utils/gift-link-access'); const {generateOptionsData, generateAuthData} = require('./utils/public-cache-keys'); diff --git a/ghost/core/core/server/api/endpoints/posts.js b/ghost/core/core/server/api/endpoints/posts.js index c943413c1ab..f19443b2e32 100644 --- a/ghost/core/core/server/api/endpoints/posts.js +++ b/ghost/core/core/server/api/endpoints/posts.js @@ -1,7 +1,7 @@ const urlUtils = require('../../../shared/url-utils').default; const models = require('../../models'); const {getCSVExportFileName} = require('./utils/csv-export-filename'); -const getPostServiceInstance = require('../../services/posts/posts-service-instance'); +const postsService = require('../../services/posts/posts-service-instance').service; const {rejectAdminApiRestrictedFieldsTransformer} = require('./utils/api-filter-utils'); const allowedIncludes = [ 'tags', @@ -22,7 +22,6 @@ const allowedIncludes = [ ]; const unsafeAttrs = ['status', 'authors', 'visibility']; -const postsService = getPostServiceInstance(); /** * @param {string} event diff --git a/ghost/core/core/server/api/endpoints/search-index-public.js b/ghost/core/core/server/api/endpoints/search-index-public.js index e4801750120..dee772abb2f 100644 --- a/ghost/core/core/server/api/endpoints/search-index-public.js +++ b/ghost/core/core/server/api/endpoints/search-index-public.js @@ -1,8 +1,7 @@ const models = require('../../models'); const urlService = require('../../services/url'); const {requiredUrlColumns} = require('./utils/serializers/input/utils/url'); -const getPostServiceInstance = require('../../services/posts/posts-service-instance'); -const postsService = getPostServiceInstance(); +const postsService = require('../../services/posts/posts-service-instance').service; const urlRelationsForRouting = () => { const withRelated = urlService.getRequiredRelations(); diff --git a/ghost/core/core/server/api/endpoints/search-index.js b/ghost/core/core/server/api/endpoints/search-index.js index 3bee7aa0053..fc166b0cfd7 100644 --- a/ghost/core/core/server/api/endpoints/search-index.js +++ b/ghost/core/core/server/api/endpoints/search-index.js @@ -1,8 +1,7 @@ const models = require('../../models'); const urlService = require('../../services/url'); const {requiredUrlColumns} = require('./utils/serializers/input/utils/url'); -const getPostServiceInstance = require('../../services/posts/posts-service-instance'); -const postsService = getPostServiceInstance(); +const postsService = require('../../services/posts/posts-service-instance').service; const urlRelationsForRouting = () => { const withRelated = urlService.getRequiredRelations(); diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js index 1ddbf1a56a1..a80b227287b 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js @@ -15,8 +15,7 @@ const utils = require('../../../index'); const postsMetaSchema = require('../../../../../../data/schema').tables.posts_meta; -const getPostServiceInstance = require('../../../../../../services/posts/posts-service-instance'); -const postsService = getPostServiceInstance(); +const postsService = require('../../../../../../services/posts/posts-service-instance').service; const commentsService = require('../../../../../../services/comments'); const memberAttribution = require('../../../../../../services/member-attribution'); diff --git a/ghost/core/core/server/lib/lexical.js b/ghost/core/core/server/lib/lexical.js index e6bde308b44..d669d80b5be 100644 --- a/ghost/core/core/server/lib/lexical.js +++ b/ghost/core/core/server/lib/lexical.js @@ -31,8 +31,7 @@ function createLexicalHtmlRenderer(onError) { function buildRenderOptions(userOptions) { if (!postsService) { - const getPostServiceInstance = require('../services/posts/posts-service-instance'); - postsService = getPostServiceInstance(); + postsService = require('../services/posts/posts-service-instance').service; } if (!serializePosts) { serializePosts = require('../api/endpoints/utils/serializers/output/posts').all; diff --git a/ghost/core/core/server/services/explore-ping/index.ts b/ghost/core/core/server/services/explore-ping/index.ts index bdbe46297cd..faad903e717 100644 --- a/ghost/core/core/server/services/explore-ping/index.ts +++ b/ghost/core/core/server/services/explore-ping/index.ts @@ -20,7 +20,7 @@ export async function init(): Promise { const ghostVersion = require('@tryghost/version'); const request = require('@tryghost/request'); const settingsCache = require('../../../shared/settings-cache'); - const posts = require('../posts/posts-service-instance'); + const posts = require('../posts/posts-service-instance').service; const members = require('../members'); const statsService = require('../stats'); @@ -30,7 +30,7 @@ export async function init(): Promise { logging, ghostVersion, request, - posts: posts(), + posts, members, statsService }); diff --git a/ghost/core/core/server/services/posts/posts-service-instance.js b/ghost/core/core/server/services/posts/posts-service-instance.js index 09ef216d7b2..abeab46c703 100644 --- a/ghost/core/core/server/services/posts/posts-service-instance.js +++ b/ghost/core/core/server/services/posts/posts-service-instance.js @@ -1,11 +1,17 @@ const PostsService = require('./posts-service'); const PostsExporter = require('./posts-exporter'); const url = require('../../../server/api/endpoints/utils/serializers/output/utils/url'); +const {lazySingleton} = require('../../../shared/lazy-singleton'); + +let instance; + +const service = lazySingleton('PostsService', () => instance); + +function init() { + if (instance) { + return instance; + } -/** - * @returns {InstanceType} instance of the PostsService - */ -const getPostServiceInstance = () => { const urlUtils = require('../../../shared/url-utils').default; const labs = require('../../../shared/labs'); const models = require('../../models'); @@ -32,7 +38,7 @@ const getPostServiceInstance = () => { settingsHelpers }); - return new PostsService({ + instance = new PostsService({ urlUtils: urlUtils, models: models, isSet: flag => labs.isSet(flag), // don't use bind, that breaks test subbing of labs @@ -40,8 +46,12 @@ const getPostServiceInstance = () => { emailService: emailService.service, postsExporter }); -}; -module.exports = getPostServiceInstance; -// exposed for testing purposes only -module.exports.PostsService = PostsService; + return instance; +} + +module.exports = { + init, + service, + PostsService +}; diff --git a/ghost/core/test/unit/api/endpoints/search-index-public.test.js b/ghost/core/test/unit/api/endpoints/search-index-public.test.js index 6541a5f838d..fa1d3e06186 100644 --- a/ghost/core/test/unit/api/endpoints/search-index-public.test.js +++ b/ghost/core/test/unit/api/endpoints/search-index-public.test.js @@ -2,14 +2,15 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); const models = require('../../../../core/server/models'); const urlService = require('../../../../core/server/services/url'); -const {PostsService} = require('../../../../core/server/services/posts/posts-service-instance'); +const posts = require('../../../../core/server/services/posts/posts-service-instance'); +const {PostsService} = posts; const searchIndexController = require('../../../../core/server/api/endpoints/search-index-public'); describe('Search index public controller', function () { let browsePostsStub; beforeEach(function () { - // the controller constructs its own PostsService instance + posts.init(); browsePostsStub = sinon.stub(PostsService.prototype, 'browsePosts').resolves({data: []}); sinon.stub(models.Tag, 'findPage').resolves({data: []}); sinon.stub(models.Author, 'findPage').resolves({data: []}); diff --git a/ghost/core/test/unit/api/endpoints/search-index.test.js b/ghost/core/test/unit/api/endpoints/search-index.test.js index f43d0584226..2b4911da303 100644 --- a/ghost/core/test/unit/api/endpoints/search-index.test.js +++ b/ghost/core/test/unit/api/endpoints/search-index.test.js @@ -2,14 +2,15 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); const models = require('../../../../core/server/models'); const urlService = require('../../../../core/server/services/url'); -const {PostsService} = require('../../../../core/server/services/posts/posts-service-instance'); +const posts = require('../../../../core/server/services/posts/posts-service-instance'); +const {PostsService} = posts; const searchIndexController = require('../../../../core/server/api/endpoints/search-index'); describe('Search index controller', function () { let browsePostsStub; beforeEach(function () { - // the controller constructs its own PostsService instance + posts.init(); browsePostsStub = sinon.stub(PostsService.prototype, 'browsePosts').resolves({data: []}); sinon.stub(models.Tag, 'findPage').resolves({data: []}); sinon.stub(models.User, 'findPage').resolves({data: []}); diff --git a/ghost/core/test/unit/server/services/posts/posts-service-instance.test.js b/ghost/core/test/unit/server/services/posts/posts-service-instance.test.js new file mode 100644 index 00000000000..fdc37816bc6 --- /dev/null +++ b/ghost/core/test/unit/server/services/posts/posts-service-instance.test.js @@ -0,0 +1,25 @@ +const assert = require('node:assert/strict'); + +describe('Posts service composition root', function () { + let posts; + + beforeEach(function () { + const modulePath = require.resolve('../../../../../core/server/services/posts/posts-service-instance'); + delete require.cache[modulePath]; + posts = require(modulePath); + }); + + it('fails loudly when the service is used before initialization', function () { + assert.throws( + () => posts.service.browsePosts({}), + /PostsService must be initialized before use/ + ); + }); + + it('returns the same instance from repeated initialization', function () { + const instance = posts.init(); + + assert.equal(posts.init(), instance); + assert.equal(Object.getPrototypeOf(posts.service), Object.getPrototypeOf(instance)); + }); +}); From fd06708b44c3088233510070891380b703256b9d Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Mon, 17 Aug 2026 10:59:08 +0100 Subject: [PATCH 6/9] Changed ping services to expose a consistent facade no ref Give every converted composition root the same init and service exports so the proposed pattern is predictable and can be generated mechanically. --- ghost/core/core/server/services/explore-ping/index.ts | 11 +++++++---- .../core/core/server/services/indexnow-ping/index.ts | 11 +++++++---- ghost/core/core/server/services/slack-ping/index.ts | 11 +++++++---- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/ghost/core/core/server/services/explore-ping/index.ts b/ghost/core/core/server/services/explore-ping/index.ts index faad903e717..da5b2efe624 100644 --- a/ghost/core/core/server/services/explore-ping/index.ts +++ b/ghost/core/core/server/services/explore-ping/index.ts @@ -1,9 +1,12 @@ import {ExplorePingService} from './explore-ping-service'; +import {lazySingleton} from '../../../shared/lazy-singleton'; -let service: ExplorePingService | undefined; +let instance: ExplorePingService | undefined; + +export const service = lazySingleton('ExplorePingService', () => instance); export async function init(): Promise { - if (service) { + if (instance) { return; } @@ -24,7 +27,7 @@ export async function init(): Promise { const members = require('../members'); const statsService = require('../stats'); - service = new ExplorePingService({ + instance = new ExplorePingService({ settingsCache, config, logging, @@ -38,5 +41,5 @@ export async function init(): Promise { // The final intention is to have this run on a schedule // For the initial version, we'll just ping when the server starts // Without waiting for the response - service.ping(); + instance.ping(); } diff --git a/ghost/core/core/server/services/indexnow-ping/index.ts b/ghost/core/core/server/services/indexnow-ping/index.ts index f942a241554..4b112559e74 100644 --- a/ghost/core/core/server/services/indexnow-ping/index.ts +++ b/ghost/core/core/server/services/indexnow-ping/index.ts @@ -1,9 +1,12 @@ import {IndexNowPingService} from './indexnow-ping-service'; +import {lazySingleton} from '../../../shared/lazy-singleton'; -let service: IndexNowPingService | undefined; +let instance: IndexNowPingService | undefined; + +export const service = lazySingleton('IndexNowPingService', () => instance); export function init(): void { - if (service) { + if (instance) { return; } @@ -15,7 +18,7 @@ export function init(): void { const logging = require('@tryghost/logging'); const events = require('../../lib/common/events'); - service = new IndexNowPingService({ + instance = new IndexNowPingService({ settingsCache, config, urlService, @@ -25,5 +28,5 @@ export function init(): void { events }); - service.subscribeEvents(); + instance.subscribeEvents(); } diff --git a/ghost/core/core/server/services/slack-ping/index.ts b/ghost/core/core/server/services/slack-ping/index.ts index 0f30f850d4c..bfa9b627674 100644 --- a/ghost/core/core/server/services/slack-ping/index.ts +++ b/ghost/core/core/server/services/slack-ping/index.ts @@ -1,9 +1,12 @@ import {SlackPingService} from './slack-ping-service'; +import {lazySingleton} from '../../../shared/lazy-singleton'; -let service: SlackPingService | undefined; +let instance: SlackPingService | undefined; + +export const service = lazySingleton('SlackPingService', () => instance); export function init(): void { - if (service) { + if (instance) { return; } @@ -15,7 +18,7 @@ export function init(): void { const urlService = require('../url'); const urlUtils = require('../../../shared/url-utils').default; - service = new SlackPingService({ + instance = new SlackPingService({ blogIcon, events, logging, @@ -25,5 +28,5 @@ export function init(): void { urlUtils }); - service.subscribeEvents(); + instance.subscribeEvents(); } From 7d7be6e3504f237a47b6b6255041a31cce85f32f Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Mon, 17 Aug 2026 11:58:01 +0100 Subject: [PATCH 7/9] Fixed service composition root consistency no ref Make every demonstrated root match the documented init and service contract, while deferring Donations access so importing Stripe before boot no longer violates initialization order. --- ghost/core/core/boot.js | 2 +- ghost/core/core/server/api/endpoints/pages.js | 2 +- .../core/server/api/endpoints/posts-public.js | 2 +- ghost/core/core/server/api/endpoints/posts.js | 2 +- .../api/endpoints/search-index-public.js | 2 +- .../core/server/api/endpoints/search-index.js | 2 +- .../utils/serializers/output/mappers/posts.js | 2 +- ghost/core/core/server/lib/lexical.js | 2 +- .../core/server/services/donations/index.js | 21 ++++++------------- .../server/services/explore-ping/index.ts | 21 ++++++++----------- .../{posts-service-instance.js => index.js} | 7 ++----- .../core/server/services/stripe/service.js | 2 +- .../api/endpoints/search-index-public.test.js | 4 ++-- .../unit/api/endpoints/search-index.test.js | 4 ++-- .../server/services/donations/index.test.js | 13 ++++++------ ...service-instance.test.js => index.test.js} | 11 +++++----- 16 files changed, 43 insertions(+), 56 deletions(-) rename ghost/core/core/server/services/posts/{posts-service-instance.js => index.js} (95%) rename ghost/core/test/unit/server/services/posts/{posts-service-instance.test.js => index.test.js} (64%) diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index dc07c8cec4e..112d5370f5b 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -344,7 +344,7 @@ async function initServices({ghostServer, config, prometheusClient}) { const mentionsService = require('./server/services/mentions'); const tagsPublic = require('./server/services/tags-public'); const postsPublic = require('./server/services/posts-public'); - const postsService = require('./server/services/posts/posts-service-instance'); + const postsService = require('./server/services/posts'); const slackNotifications = require('./server/services/slack-notifications'); const mediaInliner = require('./server/services/media-inliner'); const announcementBarService = require('./server/services/announcement-bar-service'); diff --git a/ghost/core/core/server/api/endpoints/pages.js b/ghost/core/core/server/api/endpoints/pages.js index 40237c5bb52..ca7937d8bb1 100644 --- a/ghost/core/core/server/api/endpoints/pages.js +++ b/ghost/core/core/server/api/endpoints/pages.js @@ -1,7 +1,7 @@ const models = require('../../models'); const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); -const postsService = require('../../services/posts/posts-service-instance').service; +const postsService = require('../../services/posts').service; const {rejectAdminApiRestrictedFieldsTransformer} = require('./utils/api-filter-utils'); const ALLOWED_INCLUDES = ['tags', 'authors', 'authors.roles', 'tiers', 'count.signups', 'count.paid_conversions', 'post_revisions', 'post_revisions.author']; const UNSAFE_ATTRS = ['status', 'authors', 'visibility']; diff --git a/ghost/core/core/server/api/endpoints/posts-public.js b/ghost/core/core/server/api/endpoints/posts-public.js index cf151647c2c..adbaddb6d26 100644 --- a/ghost/core/core/server/api/endpoints/posts-public.js +++ b/ghost/core/core/server/api/endpoints/posts-public.js @@ -2,7 +2,7 @@ const models = require('../../models'); const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); const postsPublicService = require('../../services/posts-public'); -const postsService = require('../../services/posts/posts-service-instance').service; +const postsService = require('../../services/posts').service; const {rejectContentApiRestrictedFieldsTransformer} = require('./utils/api-filter-utils'); const {generateGiftKeyData, applyGiftAccess} = require('./utils/gift-link-access'); const {generateOptionsData, generateAuthData} = require('./utils/public-cache-keys'); diff --git a/ghost/core/core/server/api/endpoints/posts.js b/ghost/core/core/server/api/endpoints/posts.js index f19443b2e32..581d29bd9e9 100644 --- a/ghost/core/core/server/api/endpoints/posts.js +++ b/ghost/core/core/server/api/endpoints/posts.js @@ -1,7 +1,7 @@ const urlUtils = require('../../../shared/url-utils').default; const models = require('../../models'); const {getCSVExportFileName} = require('./utils/csv-export-filename'); -const postsService = require('../../services/posts/posts-service-instance').service; +const postsService = require('../../services/posts').service; const {rejectAdminApiRestrictedFieldsTransformer} = require('./utils/api-filter-utils'); const allowedIncludes = [ 'tags', diff --git a/ghost/core/core/server/api/endpoints/search-index-public.js b/ghost/core/core/server/api/endpoints/search-index-public.js index dee772abb2f..6e2561c9e8a 100644 --- a/ghost/core/core/server/api/endpoints/search-index-public.js +++ b/ghost/core/core/server/api/endpoints/search-index-public.js @@ -1,7 +1,7 @@ const models = require('../../models'); const urlService = require('../../services/url'); const {requiredUrlColumns} = require('./utils/serializers/input/utils/url'); -const postsService = require('../../services/posts/posts-service-instance').service; +const postsService = require('../../services/posts').service; const urlRelationsForRouting = () => { const withRelated = urlService.getRequiredRelations(); diff --git a/ghost/core/core/server/api/endpoints/search-index.js b/ghost/core/core/server/api/endpoints/search-index.js index fc166b0cfd7..6b112e60750 100644 --- a/ghost/core/core/server/api/endpoints/search-index.js +++ b/ghost/core/core/server/api/endpoints/search-index.js @@ -1,7 +1,7 @@ const models = require('../../models'); const urlService = require('../../services/url'); const {requiredUrlColumns} = require('./utils/serializers/input/utils/url'); -const postsService = require('../../services/posts/posts-service-instance').service; +const postsService = require('../../services/posts').service; const urlRelationsForRouting = () => { const withRelated = urlService.getRequiredRelations(); diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js index a80b227287b..32f711393e5 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js @@ -15,7 +15,7 @@ const utils = require('../../../index'); const postsMetaSchema = require('../../../../../../data/schema').tables.posts_meta; -const postsService = require('../../../../../../services/posts/posts-service-instance').service; +const postsService = require('../../../../../../services/posts').service; const commentsService = require('../../../../../../services/comments'); const memberAttribution = require('../../../../../../services/member-attribution'); diff --git a/ghost/core/core/server/lib/lexical.js b/ghost/core/core/server/lib/lexical.js index d669d80b5be..717e93e5483 100644 --- a/ghost/core/core/server/lib/lexical.js +++ b/ghost/core/core/server/lib/lexical.js @@ -31,7 +31,7 @@ function createLexicalHtmlRenderer(onError) { function buildRenderOptions(userOptions) { if (!postsService) { - postsService = require('../services/posts/posts-service-instance').service; + postsService = require('../services/posts').service; } if (!serializePosts) { serializePosts = require('../api/endpoints/utils/serializers/output/posts').all; diff --git a/ghost/core/core/server/services/donations/index.js b/ghost/core/core/server/services/donations/index.js index d51eef0beef..33d527671ab 100644 --- a/ghost/core/core/server/services/donations/index.js +++ b/ghost/core/core/server/services/donations/index.js @@ -1,8 +1,12 @@ +const {lazySingleton} = require('../../../shared/lazy-singleton'); + let repository; +const service = lazySingleton('DonationRepository', () => repository); + function init() { if (repository) { - return repository; + return; } const {DonationPaymentEvent: DonationPaymentEventModel} = require('../../models'); @@ -11,22 +15,9 @@ function init() { repository = new DonationBookshelfRepository({ DonationPaymentEventModel }); - - return repository; -} - -function getRepository() { - if (!repository) { - const {InternalServerError} = require('@tryghost/errors'); - throw new InternalServerError({ - message: 'Donation repository must be initialized before use' - }); - } - - return repository; } module.exports = { init, - getRepository + service }; diff --git a/ghost/core/core/server/services/explore-ping/index.ts b/ghost/core/core/server/services/explore-ping/index.ts index da5b2efe624..a243dfd1d0d 100644 --- a/ghost/core/core/server/services/explore-ping/index.ts +++ b/ghost/core/core/server/services/explore-ping/index.ts @@ -12,18 +12,11 @@ export async function init(): Promise { const config = require('../../../shared/config'); - // The explore ping is a background "phone home" request. It should not run - // in the test environment (cf. the update-check service, which gates on the - // same environments), where there is no explore URL configured. - if (!config.isProductionOrDevelopment()) { - return; - } - const logging = require('@tryghost/logging'); const ghostVersion = require('@tryghost/version'); const request = require('@tryghost/request'); const settingsCache = require('../../../shared/settings-cache'); - const posts = require('../posts/posts-service-instance').service; + const posts = require('../posts').service; const members = require('../members'); const statsService = require('../stats'); @@ -38,8 +31,12 @@ export async function init(): Promise { statsService }); - // The final intention is to have this run on a schedule - // For the initial version, we'll just ping when the server starts - // Without waiting for the response - instance.ping(); + // The explore ping is a background "phone home" request. Construct the + // service in every environment so init() always fulfils the service + // contract, but only trigger the request in production or development. + if (config.isProductionOrDevelopment()) { + // The final intention is to have this run on a schedule. For the + // initial version, ping when the server starts without awaiting it. + instance.ping(); + } } diff --git a/ghost/core/core/server/services/posts/posts-service-instance.js b/ghost/core/core/server/services/posts/index.js similarity index 95% rename from ghost/core/core/server/services/posts/posts-service-instance.js rename to ghost/core/core/server/services/posts/index.js index abeab46c703..a5fa80e2192 100644 --- a/ghost/core/core/server/services/posts/posts-service-instance.js +++ b/ghost/core/core/server/services/posts/index.js @@ -9,7 +9,7 @@ const service = lazySingleton('PostsService', () => instance); function init() { if (instance) { - return instance; + return; } const urlUtils = require('../../../shared/url-utils').default; @@ -46,12 +46,9 @@ function init() { emailService: emailService.service, postsExporter }); - - return instance; } module.exports = { init, - service, - PostsService + service }; diff --git a/ghost/core/core/server/services/stripe/service.js b/ghost/core/core/server/services/stripe/service.js index a7f42f1dbe2..3d4459ffdc1 100644 --- a/ghost/core/core/server/services/stripe/service.js +++ b/ghost/core/core/server/services/stripe/service.js @@ -9,7 +9,7 @@ const events = require('../../lib/common/events'); const models = require('../../models'); const {getConfig} = require('./config'); const settingsHelpers = require('../settings-helpers'); -const donationRepository = require('../donations').getRepository(); +const donationRepository = require('../donations').service; const giftService = require('../gifts'); const staffService = require('../staff'); const labs = require('../../../shared/labs'); diff --git a/ghost/core/test/unit/api/endpoints/search-index-public.test.js b/ghost/core/test/unit/api/endpoints/search-index-public.test.js index fa1d3e06186..fc221a9896f 100644 --- a/ghost/core/test/unit/api/endpoints/search-index-public.test.js +++ b/ghost/core/test/unit/api/endpoints/search-index-public.test.js @@ -2,8 +2,8 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); const models = require('../../../../core/server/models'); const urlService = require('../../../../core/server/services/url'); -const posts = require('../../../../core/server/services/posts/posts-service-instance'); -const {PostsService} = posts; +const posts = require('../../../../core/server/services/posts'); +const PostsService = require('../../../../core/server/services/posts/posts-service'); const searchIndexController = require('../../../../core/server/api/endpoints/search-index-public'); describe('Search index public controller', function () { diff --git a/ghost/core/test/unit/api/endpoints/search-index.test.js b/ghost/core/test/unit/api/endpoints/search-index.test.js index 2b4911da303..9d7ede79101 100644 --- a/ghost/core/test/unit/api/endpoints/search-index.test.js +++ b/ghost/core/test/unit/api/endpoints/search-index.test.js @@ -2,8 +2,8 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); const models = require('../../../../core/server/models'); const urlService = require('../../../../core/server/services/url'); -const posts = require('../../../../core/server/services/posts/posts-service-instance'); -const {PostsService} = posts; +const posts = require('../../../../core/server/services/posts'); +const PostsService = require('../../../../core/server/services/posts/posts-service'); const searchIndexController = require('../../../../core/server/api/endpoints/search-index'); describe('Search index controller', function () { diff --git a/ghost/core/test/unit/server/services/donations/index.test.js b/ghost/core/test/unit/server/services/donations/index.test.js index 6ef3473e773..595b5b39e6c 100644 --- a/ghost/core/test/unit/server/services/donations/index.test.js +++ b/ghost/core/test/unit/server/services/donations/index.test.js @@ -11,15 +11,16 @@ describe('Donations composition root', function () { it('fails loudly when the repository is used before initialization', function () { assert.throws( - () => donations.getRepository(), - /Donation repository must be initialized before use/ + () => donations.service.create({}), + /DonationRepository must be initialized before use/ ); }); - it('returns the same repository from repeated initialization', function () { - const repository = donations.init(); + it('initializes idempotently', function () { + donations.init(); + const prototype = Object.getPrototypeOf(donations.service); - assert.equal(donations.init(), repository); - assert.equal(donations.getRepository(), repository); + assert.equal(donations.init(), undefined); + assert.equal(Object.getPrototypeOf(donations.service), prototype); }); }); diff --git a/ghost/core/test/unit/server/services/posts/posts-service-instance.test.js b/ghost/core/test/unit/server/services/posts/index.test.js similarity index 64% rename from ghost/core/test/unit/server/services/posts/posts-service-instance.test.js rename to ghost/core/test/unit/server/services/posts/index.test.js index fdc37816bc6..3c547418ba4 100644 --- a/ghost/core/test/unit/server/services/posts/posts-service-instance.test.js +++ b/ghost/core/test/unit/server/services/posts/index.test.js @@ -4,7 +4,7 @@ describe('Posts service composition root', function () { let posts; beforeEach(function () { - const modulePath = require.resolve('../../../../../core/server/services/posts/posts-service-instance'); + const modulePath = require.resolve('../../../../../core/server/services/posts'); delete require.cache[modulePath]; posts = require(modulePath); }); @@ -16,10 +16,11 @@ describe('Posts service composition root', function () { ); }); - it('returns the same instance from repeated initialization', function () { - const instance = posts.init(); + it('initializes idempotently', function () { + posts.init(); + const prototype = Object.getPrototypeOf(posts.service); - assert.equal(posts.init(), instance); - assert.equal(Object.getPrototypeOf(posts.service), Object.getPrototypeOf(instance)); + assert.equal(posts.init(), undefined); + assert.equal(Object.getPrototypeOf(posts.service), prototype); }); }); From d8d26302658d5ecf6e9e7a9ebc008b437d6d3957 Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Mon, 17 Aug 2026 12:50:08 +0100 Subject: [PATCH 8/9] Fixed Tinybird configuration refresh no ref Keep one initialized service instance while preserving runtime configuration changes used by the token endpoint and acceptance tests. --- .../core/server/services/tinybird/index.js | 1 + .../services/tinybird/tinybird-service.js | 20 ++++++++++++++++--- .../tinybird/tinybird-service.test.js | 19 ++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/ghost/core/core/server/services/tinybird/index.js b/ghost/core/core/server/services/tinybird/index.js index 23e375beee1..109e519c7c3 100644 --- a/ghost/core/core/server/services/tinybird/index.js +++ b/ghost/core/core/server/services/tinybird/index.js @@ -23,6 +23,7 @@ function init() { instance = new TinybirdService({ tinybirdConfig, + getTinybirdConfig: () => config.get('tinybird'), siteUuid }); } diff --git a/ghost/core/core/server/services/tinybird/tinybird-service.js b/ghost/core/core/server/services/tinybird/tinybird-service.js index f9316e8b6af..9ca98dad5d1 100644 --- a/ghost/core/core/server/services/tinybird/tinybird-service.js +++ b/ghost/core/core/server/services/tinybird/tinybird-service.js @@ -19,6 +19,7 @@ const jwt = require('jsonwebtoken'); /** * @typedef {Object} TinybirdConstructorOptions * @property {TinybirdConfig} tinybirdConfig - Tinybird configuration object + * @property {() => TinybirdConfig} [getTinybirdConfig] - Returns the current Tinybird configuration * @property {string} siteUuid - Unique identifier for the site */ @@ -81,9 +82,15 @@ class TinybirdService { * Creates a new TinybirdService instance * @param {TinybirdConstructorOptions} options - Configuration options */ - constructor({tinybirdConfig, siteUuid}) { + constructor({tinybirdConfig, getTinybirdConfig = () => tinybirdConfig, siteUuid}) { + this.getTinybirdConfig = getTinybirdConfig; + this.defaultSiteUuid = siteUuid; + this._configure(tinybirdConfig); + } + + _configure(tinybirdConfig) { this.tinybirdConfig = tinybirdConfig; - this.siteUuid = tinybirdConfig?.stats?.id || siteUuid; + this.siteUuid = tinybirdConfig?.stats?.id || this.defaultSiteUuid; // Flags for determining which token to use // We should aim to simplify this in the future @@ -100,7 +107,14 @@ class TinybirdService { * For now we need to remain backwards compatible with the old stats token * @returns {{token: string, exp?: number}|null} Object with token and optional exp, or null if generation fails */ - getToken({name = `tinybird-jwt-${this.siteUuid}`, expiresInMinutes = 180} = {}) { + getToken({name, expiresInMinutes = 180} = {}) { + const tinybirdConfig = this.getTinybirdConfig(); + if (tinybirdConfig !== this.tinybirdConfig) { + this._configure(tinybirdConfig); + } + + name ??= `tinybird-jwt-${this.siteUuid}`; + // Prefer JWT tokens if enabled if (this.isJwtEnabled) { // Generate a new JWT token if it doesn't exist or is expired diff --git a/ghost/core/test/unit/server/services/tinybird/tinybird-service.test.js b/ghost/core/test/unit/server/services/tinybird/tinybird-service.test.js index a19a2172e3d..ebc2b28b9cd 100644 --- a/ghost/core/test/unit/server/services/tinybird/tinybird-service.test.js +++ b/ghost/core/test/unit/server/services/tinybird/tinybird-service.test.js @@ -172,5 +172,24 @@ describe('TinybirdService', function () { assert.equal(result.token, 'stats-token'); assert.equal(result.exp, undefined); }); + + it('should use updated configuration without creating another service instance', function () { + let currentConfig = null; + tinybirdService = new TinybirdService({ + tinybirdConfig: currentConfig, + getTinybirdConfig: () => currentConfig, + siteUuid + }); + + assert.equal(tinybirdService.getToken(), null); + + currentConfig = { + stats: { + token: 'updated-stats-token' + } + }; + + assert.deepEqual(tinybirdService.getToken(), {token: 'updated-stats-token'}); + }); }); }); From 48d7bd3d6b0ffa74e9cd1ff2ba171df6471abcfe Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Mon, 17 Aug 2026 13:37:09 +0100 Subject: [PATCH 9/9] Retry CI after runner failure