Changed the URL service to answer from lazy everywhere - #29799
Conversation
|
| Command | Status | Duration | Result |
|---|---|---|---|
nx run ghost:test:ci:integration |
✅ Succeeded | 3m 9s | View ↗ |
nx run ghost:test:integration |
✅ Succeeded | 2m 11s | View ↗ |
nx run ghost:test:legacy |
✅ Succeeded | 2m 30s | View ↗ |
nx run ghost:test:e2e |
✅ Succeeded | 1m 50s | View ↗ |
nx run-many -t test:unit -p ghost |
✅ Succeeded | 33s | View ↗ |
nx run ghost-monorepo:lint:boundaries |
✅ Succeeded | 22s | View ↗ |
nx run @tryghost/admin:build |
✅ Succeeded | 5s | View ↗ |
nx run-many -t lint -p ghost,ghost-monorepo |
✅ Succeeded | 1s | View ↗ |
nx run-many --target=build --projects=tag:publi... |
✅ Succeeded | <1s | View ↗ |
💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗
☁️ Nx Cloud last updated this comment at 2026-08-06 10:03:02 UTC
WalkthroughThe URL service always initializes the lazy service and facade. Lazy routing handles thin-resource fallbacks and reports distinct resolution, hook, and comparison errors. Dynamic routing initializes on every boot and uses facade readiness. Serializers track forced URL columns by router type. Tests and URL-generating consumers now use the facade or Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 2 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (2 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ast-grep (0.45.0)ghost/core/test/e2e-api/members/webhooks.test.jsast-grep timed out on this file Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
This PR makes the lazy URL service the authoritative source for URL reads across Ghost Core, ensuring URL generation works consistently in backend-only boots (APIs/background services) and adding safer error handling when lazy URL resolution encounters thin resources.
Changes:
- Make
LazyUrlServicealways constructed and ensureUrlServiceFacadeanswers reads from lazy (no compare mode). - Run dynamic routing on every boot (including backend-only boots) so routers are registered for URL generation even without the site app.
- Update serializer URL column forcing/removal mechanics and refresh affected unit/e2e snapshots now that lazy URL generation is exercised.
Reviewed changes
Copilot reviewed 34 out of 34 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| ghost/core/core/boot.js | Always initializes dynamic routing; ensures router registration happens even when frontend is false. |
| ghost/core/core/server/services/url/index.js | Always wires up LazyUrlService + facade (compare disabled) so lazy is authoritative. |
| ghost/core/core/server/services/url/url-service-facade.ts | Routes reads to lazy, adds guarded degradation for thin-resource errors, and refines lazy error reporting. |
| ghost/core/core/server/services/url/lazy-url-service.ts | Exposes a public notFoundUrl() used by the facade as a safe fallback. |
| ghost/core/core/server/services/route-settings/dynamic-routing-service.js | Upload readiness now checks urlService.facade.hasFinished() (lazy readiness) instead of eager. |
| ghost/core/core/server/api/endpoints/utils/serializers/input/utils/url.js | Records forced URL columns as {routerType, columns} to avoid cross-fetch stripping. |
| ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js | Strips force-loaded columns only for the matching routerType; avoids stripping nested relations incorrectly. |
| ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/tags.js | Updates forced-column stripping to the new {routerType, columns} shape. |
| ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/users.js | Strips force-loaded author URL columns after URL computation (prevents leaking forced fields). |
| ghost/core/test/utils/url-service-utils.js | Adds helper for generating URLs via the facade; resets now also clears facade state. |
| ghost/core/test/unit/server/services/url/url-service-facade.test.js | Adds coverage for thin-resource degradation vs unexpected lazy failures; updates lazy hook error code expectations. |
| ghost/core/test/unit/server/services/route-settings/dynamic-routing-service.test.ts | Adjusts tests to stub readiness on the facade rather than eager service. |
| ghost/core/test/unit/frontend/helpers/url.test.js | Updates helper stubbing seam to urlService.facade.getUrlForResource. |
| ghost/core/test/unit/frontend/helpers/ghost-head.test.js | Stubs URL generation through the facade. |
| ghost/core/test/unit/api/canary/utils/serializers/input/utils/url.test.js | Updates expectations for new forced URL column metadata shape. |
| ghost/core/test/unit/api/canary/utils/serializers/output/mapper.test.js | Adds/updates tests ensuring forced columns are stripped only for the relevant fetch/routerType. |
| ghost/core/test/e2e-api/members/webhooks.test.js | Switches URL derivation in tests to use the facade-based helper. |
| ghost/core/test/e2e-api/members/donation-checkout-session.test.js | Switches URL derivation in tests to use the facade-based helper. |
| ghost/core/test/e2e-api/members/create-stripe-checkout-session.test.js | Switches URL derivation in tests to use the facade-based helper. |
| ghost/core/test/e2e-api/members-comments/comments-lazy-url-parity.test.js | Removes compare-mode scaffolding and asserts lazy is authoritative; checks for resolution degradation logs. |
| ghost/core/test/e2e-api/admin/users.test.js | Updates assertions to expect real author URLs (no eager /404/ behavior). |
| ghost/core/test/e2e-api/admin/tags.test.js | Updates assertions for postless tags to return their real URL (per accepted behavior). |
| ghost/core/test/legacy/api/admin/snapshots/authentication.test.js.snap | Snapshot updates reflecting real generated URLs vs /404/ fixture artifacts. |
| ghost/core/test/integration/services/email-service/snapshots/cards.test.js.snap | Snapshot updates reflecting resolved post URLs. |
| ghost/core/test/e2e-webhooks/snapshots/pages.test.js.snap | Snapshot updates reflecting tag URL fields no longer being /404/. |
| ghost/core/test/e2e-api/content/snapshots/search-index.test.js.snap | Snapshot updates for content-length changes due to real URLs. |
| ghost/core/test/e2e-api/content/snapshots/posts.test.js.snap | Snapshot updates replacing /404/ URLs with real post/tag/author URLs. |
| ghost/core/test/e2e-api/content/snapshots/authors.test.js.snap | Snapshot updates for real author URLs and content-length changes. |
| ghost/core/test/e2e-api/admin/snapshots/users.test.js.snap | Snapshot updates reflecting real author URLs. |
| ghost/core/test/e2e-api/admin/snapshots/search-index.test.js.snap | Snapshot updates for content-length changes due to real URLs. |
| ghost/core/test/e2e-api/admin/snapshots/posts.test.js.snap | Snapshot updates for real post/tag/author URLs and export CSV rows. |
| ghost/core/test/e2e-api/admin/snapshots/post-analytics-export.test.js.snap | Snapshot updates replacing /404/ URLs with real post URLs in exports. |
| ghost/core/test/e2e-api/admin/snapshots/pages.test.js.snap | Snapshot updates for content-length changes. |
| ghost/core/test/e2e-api/admin/snapshots/activity-feed.test.js.snap | Snapshot updates for content-length changes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
ghost/core/core/server/services/url/lazy-url-service.ts (1)
457-470: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winPreserve the subdirectory in 404 URLs.
notFoundUrl()is the shared fallback for unknown and filtered resources, so thewithSubdirectorybranch should behave like_formatPath(). Line 469 generates/404/instead of the configured subdirectory-prefixed 404 URL.Proposed fix
if (options.withSubdirectory) { - return this.urlUtils.createUrl('/404/', false); + return this.urlUtils.createUrl('/404/', false, true); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@ghost/core/core/server/services/url/lazy-url-service.ts` around lines 457 - 470, Update notFoundUrl() so its withSubdirectory branch generates the configured subdirectory-prefixed 404 URL, matching _formatPath() instead of hardcoding an unprefixed /404/ path. Preserve the existing absolute and default-path behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@ghost/core/core/server/services/url/url-service-facade.ts`:
- Line 160: Update the getUrlForResource lazy-error report to include the
optional serializer context by passing the same {serializer:
options.serializerContext} context used by the compare path to _reportLazyError.
---
Outside diff comments:
In `@ghost/core/core/server/services/url/lazy-url-service.ts`:
- Around line 457-470: Update notFoundUrl() so its withSubdirectory branch
generates the configured subdirectory-prefixed 404 URL, matching _formatPath()
instead of hardcoding an unprefixed /404/ path. Preserve the existing absolute
and default-path behavior.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 42b94f70-86c6-4566-86dc-aee247d8e272
⛔ Files ignored due to path filters (12)
ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snapis excluded by!**/*.snapghost/core/test/e2e-api/admin/__snapshots__/pages.test.js.snapis excluded by!**/*.snapghost/core/test/e2e-api/admin/__snapshots__/post-analytics-export.test.js.snapis excluded by!**/*.snapghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snapis excluded by!**/*.snapghost/core/test/e2e-api/admin/__snapshots__/search-index.test.js.snapis excluded by!**/*.snapghost/core/test/e2e-api/admin/__snapshots__/users.test.js.snapis excluded by!**/*.snapghost/core/test/e2e-api/content/__snapshots__/authors.test.js.snapis excluded by!**/*.snapghost/core/test/e2e-api/content/__snapshots__/posts.test.js.snapis excluded by!**/*.snapghost/core/test/e2e-api/content/__snapshots__/search-index.test.js.snapis excluded by!**/*.snapghost/core/test/e2e-webhooks/__snapshots__/pages.test.js.snapis excluded by!**/*.snapghost/core/test/integration/services/email-service/__snapshots__/cards.test.js.snapis excluded by!**/*.snapghost/core/test/legacy/api/admin/__snapshots__/authentication.test.js.snapis excluded by!**/*.snap
📒 Files selected for processing (22)
ghost/core/core/boot.jsghost/core/core/server/api/endpoints/utils/serializers/input/utils/url.jsghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.jsghost/core/core/server/api/endpoints/utils/serializers/output/mappers/tags.jsghost/core/core/server/api/endpoints/utils/serializers/output/mappers/users.jsghost/core/core/server/services/route-settings/dynamic-routing-service.jsghost/core/core/server/services/url/index.jsghost/core/core/server/services/url/lazy-url-service.tsghost/core/core/server/services/url/url-service-facade.tsghost/core/test/e2e-api/admin/tags.test.jsghost/core/test/e2e-api/admin/users.test.jsghost/core/test/e2e-api/members-comments/comments-lazy-url-parity.test.jsghost/core/test/e2e-api/members/create-stripe-checkout-session.test.jsghost/core/test/e2e-api/members/donation-checkout-session.test.jsghost/core/test/e2e-api/members/webhooks.test.jsghost/core/test/unit/api/canary/utils/serializers/input/utils/url.test.jsghost/core/test/unit/api/canary/utils/serializers/output/mapper.test.jsghost/core/test/unit/frontend/helpers/ghost-head.test.jsghost/core/test/unit/frontend/helpers/url.test.jsghost/core/test/unit/server/services/route-settings/dynamic-routing-service.test.tsghost/core/test/unit/server/services/url/url-service-facade.test.jsghost/core/test/utils/url-service-utils.js
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 34 out of 34 changed files in this pull request and generated no new comments.
Suppressed comments (1)
ghost/core/test/utils/url-service-utils.js:44
module.exports.resetends with a trailing comma, which turns the next assignment into part of a comma-expression. It works but is easy to misread and can lead to accidental grouping of exports when edits are made. Prefer terminating the assignment with a semicolon and keeping each export as its own statement.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated no new comments.
Suppressed comments (1)
ghost/core/test/utils/url-service-utils.js:44
urlServiceUtils.reset()only callsurlService.softReset(), which resets the eager service but does not reset the facade/lazy backend state. With lazy now authoritative, this can leave router configs and lazy readiness state behind across DB resets (the same accumulation issue this PR is addressing viaresetGenerators).
Consider resetting the facade here as well so any call path that uses reset() (eg test/utils/db-utils.js) fully resets URL state under lazy mode.
8f173b9 to
a2d7268
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated no new comments.
Suppressed comments (1)
ghost/core/core/boot.js:310
- The comment says initFrontend has already built the site router, but
initFrontend()only initializes helpers. The site router is created when the frontend express app is set up (viacore/frontend/web/routes.js→routing.routerManager.init). Updating this avoids future confusion when tracing boot order.
// With a frontend, initFrontend has already called this to build the site
// app's router. Without one there is nothing to mount, but the URL service
// still has to be handed to RouterManager before the routers register — so
// call the same init and discard the express router it returns.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #29799 +/- ##
==========================================
- Coverage 75.59% 75.54% -0.06%
==========================================
Files 1615 1615
Lines 142712 142803 +91
Branches 17659 17639 -20
==========================================
- Hits 107882 107875 -7
- Misses 33751 33853 +102
+ Partials 1079 1075 -4
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
ref https://linear.app/ghost/issue/HKG-1823/ - shadow comparison ran across Ghost(Pro) for the observation window, so lazy becomes the answer source rather than a shadow. `compare: false` is the switch; the `lazyRouting` gate goes too, so no configuration is left in which this is untested. Eager is still built but unread, until HKG-1824 deletes it - dynamic routing now runs on every boot. It mounts routers on the site app and registers them with the URL service; only the first needs a frontend, and the second is needed wherever URLs are built — which includes the APIs. Without it a backend-only boot has no routers and resolves everything to /404/ - upload readiness reads the facade. With lazy authoritative the facade registers routers on lazy alone, so eager never gets a url generator, its init queue never emits `ended`, and its `finished` stays false — which would roll back every routes.yaml upload - a thin resource degrades to /404/ under LAZY_URL_RESOLUTION_ERROR instead of 500ing. Only that class; anything else is a backend bug and propagates, since a silent /404/ on a page that does route gets indexed - forced URL columns now carry the fetch they were computed for. The posts mapper passes its own frame to the nested tag and author mappers, and those resources share `slug`/`status` with posts, so each was deleting the other's fields off data the caller asked for - e2e snapshots move from /404/ to real URLs. Backend-only boots read URLs from a 2021 fixture only eager can load, so the snapshots were asserting that file, not URL generation — one even pinned Joe Bloggs' URL on a user whose slug is `test`. Empty tags and authors keep lazy's real URL per HKG-1920
ref https://linear.app/ghost/issue/HKG-1823 - #29797 stopped a post's forced URL columns being stripped from its included tags by handing the tag mapper a copy of the frame with `forcedUrlColumns` cleared - this branch already prevents the same thing more generally: `forcedUrlColumns` now carries its `routerType`, and each mapper strips only when it matches, so a tag nested under a posts request is never touched - keeping both left two mechanisms enforcing one invariant, and the copy was the weaker of the two — it covered tags but not authors, which the routerType gate does - dropping the spread also removes a trap: `{...frame}` returns a plain object, so a `Frame` method called anywhere in the tag path would have thrown for nested tags only
ref https://linear.app/ghost/issue/HKG-1823 - #29797 landed on main while this branch was out and established the rule: `findOne` forges the model with its lookup keys before the fetch, so a read already carries them, and forcing them in means stripping a field the caller is served today. It applied that to `id` only - the same holds for every other lookup key, and lazy needs `slug` for the permalink — so `posts/slug/:slug/?fields=title,url` was force-loading a slug the model already had and then stripping it back out, dropping a field from the response that eager serves - generalised the `id` carve-out to every key in `frame.data`, which folds the two mechanisms into one and leaves the Content API response unchanged - the assertions under `forceUrlRelationsWhenLazy` still pinned the array shape of `forcedUrlColumns` from before it carried its `routerType`; updated to match the rest
ref https://linear.app/ghost/issue/HKG-1823 - a read's lookup keys are now selected as well as exempt from stripping. The forged value is whatever the request asked for and the lookup matches case-insensitively, so `posts/slug/Welcome/?fields=url` would otherwise have built its URL from `Welcome` and shipped `/Welcome/` - the thin-resource report carries the serializer context the compare path already passed, so the degraded /404/ names the fetch that produced it. That /404/ is silent to the caller, so the report is the only way back - corrected the comments this branch made false: `configure()` no longer runs in a boot where `start()` never does, and the mid-file requires in `services/url` had lost the note on why they cannot be hoisted - said why where a reviewer had to ask: the backend-only `routerManager.init`, why a thin resource degrades instead of throwing, and why the two flipped empty-tag/author assertions are HKG-1920 rather than a regression
ref https://linear.app/ghost/issue/HKG-1823 - review read `notFoundUrl`'s two-argument `createUrl` as dropping the subdirectory on a subdirectory install, and asked for a third argument - it does not: `createUrl` takes the subdirectory from its own base whenever the url is relative, and the third argument is `trailingSlash`, which `/404/` already has. Adding it would have made lazy diverge from the eager miss path this deliberately mirrors - the reading is easy to arrive at because the unit suite's url-utils stand-in names that parameter `withSubdirectory` and gates its `/sub` prefix on it, which the real one does not — so no existing test could settle the question - pinned it the way `url-service.test.js` pins the eager side, on the arguments rather than the result, and said so at the call site
ref https://linear.app/ghost/issue/HKG-1823 - review asked for `urlServiceUtils.reset()` to reset the facade too, since it only soft-resets eager and lazy is now authoritative - it must not: `reset()` is the data path, called on a DB truncate or snapshot restore between tests inside one boot. Eager needs it because it holds in-memory copies of DB rows, and it keeps its generators through a softReset - lazy caches nothing from the DB. It holds the router configs read from routes.yaml, which a DB reset does not invalidate, so its softReset analogue is a no-op. Its generators analogue is already reset per boot, in `resetGenerators`, which is where this branch added `facade.reset()` - measured rather than argued: adding it fails 9 tests across 3 files of the e2e project, which is 141 files and 2001 assertions green without it
…ngs read ref https://linear.app/ghost/issue/HKG-1823 - `reloadFrontend` called `urlService.facade.reset()` three lines before `await routeSettings.loadRouteSettings()`, leaving a window with no router configs for the length of that read — a network round trip on Pro - harmless while eager answered reads. Once lazy is authoritative it is not: `reset()` also clears `routersReady`, which gates the maintenance middleware, so the window 503s the site and the Admin API. Non-HTTP callers skip that gate entirely and would build URLs against zero routers - worse on the failure path: nothing re-registers after a failed reload, so a rejected settings read left the reset applied and 503ing with no recovery but a restart - moved the reset after the await, immediately before `siteApp.reload()`. It still runs before re-registration, so configs cannot pile up across reloads, and a failed read now leaves the previous routers serving - #29792 carries the same fix, but that is the eager removal and lands after this, which is the PR that makes the window harmful
d54c0ef to
5e4f698
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
ghost/core/test/unit/api/canary/utils/serializers/input/utils/url.test.js (1)
111-120: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winAssert the forced-column metadata for an ID lookup.
This test checks that
idremains inframe.options.columns, but it does not check thatidis absent fromframe.forcedUrlColumns. Add that assertion here, or verify that the output-mapper test covers this exact contract.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@ghost/core/test/unit/api/canary/utils/serializers/input/utils/url.test.js` around lines 111 - 120, The test for forceUrlRelationsWhenLazy should also verify the forced-column metadata for an ID lookup. After calling urlUtil.forceUrlRelationsWhenLazy, assert that frame.forcedUrlColumns does not contain id, while preserving the existing frame.options.columns assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@ghost/core/test/unit/api/canary/utils/serializers/input/utils/url.test.js`:
- Around line 111-120: The test for forceUrlRelationsWhenLazy should also verify
the forced-column metadata for an ID lookup. After calling
urlUtil.forceUrlRelationsWhenLazy, assert that frame.forcedUrlColumns does not
contain id, while preserving the existing frame.options.columns assertion.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 6eda6835-a6ee-4d5b-8399-977bed5b4235
⛔ Files ignored due to path filters (12)
ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snapis excluded by!**/*.snapghost/core/test/e2e-api/admin/__snapshots__/pages.test.js.snapis excluded by!**/*.snapghost/core/test/e2e-api/admin/__snapshots__/post-analytics-export.test.js.snapis excluded by!**/*.snapghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snapis excluded by!**/*.snapghost/core/test/e2e-api/admin/__snapshots__/search-index.test.js.snapis excluded by!**/*.snapghost/core/test/e2e-api/admin/__snapshots__/users.test.js.snapis excluded by!**/*.snapghost/core/test/e2e-api/content/__snapshots__/authors.test.js.snapis excluded by!**/*.snapghost/core/test/e2e-api/content/__snapshots__/posts.test.js.snapis excluded by!**/*.snapghost/core/test/e2e-api/content/__snapshots__/search-index.test.js.snapis excluded by!**/*.snapghost/core/test/e2e-webhooks/__snapshots__/pages.test.js.snapis excluded by!**/*.snapghost/core/test/integration/services/email-service/__snapshots__/cards.test.js.snapis excluded by!**/*.snapghost/core/test/legacy/api/admin/__snapshots__/authentication.test.js.snapis excluded by!**/*.snap
📒 Files selected for processing (25)
ghost/core/core/boot.jsghost/core/core/bridge.jsghost/core/core/server/api/endpoints/utils/serializers/input/utils/url.jsghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.jsghost/core/core/server/api/endpoints/utils/serializers/output/mappers/tags.jsghost/core/core/server/api/endpoints/utils/serializers/output/mappers/users.jsghost/core/core/server/services/route-settings/dynamic-routing-service.jsghost/core/core/server/services/url/index.jsghost/core/core/server/services/url/lazy-url-service.tsghost/core/core/server/services/url/url-service-facade.tsghost/core/test/e2e-api/admin/tags.test.jsghost/core/test/e2e-api/admin/users.test.jsghost/core/test/e2e-api/members-comments/comments-lazy-url-parity.test.jsghost/core/test/e2e-api/members/create-stripe-checkout-session.test.jsghost/core/test/e2e-api/members/donation-checkout-session.test.jsghost/core/test/e2e-api/members/webhooks.test.jsghost/core/test/unit/api/canary/utils/serializers/input/utils/url.test.jsghost/core/test/unit/api/canary/utils/serializers/output/mapper.test.jsghost/core/test/unit/bridge.test.jsghost/core/test/unit/frontend/helpers/ghost-head.test.jsghost/core/test/unit/frontend/helpers/url.test.jsghost/core/test/unit/server/services/route-settings/dynamic-routing-service.test.tsghost/core/test/unit/server/services/url/lazy-url-service.test.jsghost/core/test/unit/server/services/url/url-service-facade.test.jsghost/core/test/utils/url-service-utils.js
🚧 Files skipped from review as they are similar to previous changes (21)
- ghost/core/test/unit/frontend/helpers/ghost-head.test.js
- ghost/core/core/boot.js
- ghost/core/test/e2e-api/admin/tags.test.js
- ghost/core/test/unit/api/canary/utils/serializers/output/mapper.test.js
- ghost/core/test/unit/server/services/route-settings/dynamic-routing-service.test.ts
- ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/tags.js
- ghost/core/test/unit/server/services/url/url-service-facade.test.js
- ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/users.js
- ghost/core/test/unit/server/services/url/lazy-url-service.test.js
- ghost/core/core/server/services/url/lazy-url-service.ts
- ghost/core/core/server/services/route-settings/dynamic-routing-service.js
- ghost/core/test/e2e-api/members/create-stripe-checkout-session.test.js
- ghost/core/test/e2e-api/members/webhooks.test.js
- ghost/core/test/utils/url-service-utils.js
- ghost/core/test/e2e-api/admin/users.test.js
- ghost/core/test/unit/frontend/helpers/url.test.js
- ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js
- ghost/core/core/server/api/endpoints/utils/serializers/input/utils/url.js
- ghost/core/core/server/services/url/url-service-facade.ts
- ghost/core/test/e2e-api/members/donation-checkout-session.test.js
- ghost/core/core/server/services/url/index.js

ref https://linear.app/ghost/issue/HKG-1823/
Makes
LazyUrlServicethe only URL service.What changes
UrlServiceFacade.isLazy()is!!lazyUrlService && !compare, socompare: falseis the switch. Theconfig.get('lazyRouting')gate goes with it, so lazy answers for Pro, self-hosters and CI alike — there is no configuration left in which this change is untested. Eager is still constructed and initialised, just never consulted for reads; it is deleted in HKG-1824.Three things had to come with it.
1. Dynamic routing now runs on every boot. It was frontend-gated, but it does two separable jobs: mounting routers on the site app, and registering them with the URL service. Only the first needs a frontend — the second is needed wherever URLs are built, which includes the Admin and Content APIs, the email service and webhooks. Backend-only boots did neither, so lazy had no routers and resolved everything to
/404/.RouterManageritself is untouched: when there is no site app, boot calls the existingrouterManager.init({urlService})and discards the express router it returns.2. Upload readiness moved to the facade. With lazy authoritative the facade registers routers on the lazy backend only, so eager never gets a
UrlGenerator. Generators are the only callers ofqueue.register, so eager's init queue never emitsendedand itsfinishedflag stays false forever.dynamic-routing-servicepolled exactly that — everyroutes.yamlupload would have retried six times and rolled itself back.3. The lazy read is guarded. Lazy refuses a thin resource by throwing. While comparing, that throw landed inside
_compare's try/catch and became a log line — which is why the residual thin-resource errors were acceptable noise. Answering from lazy puts it on the request path, where it is a 500 or a failed theme render. It now degrades to the same/404/eager returns for an unroutable resource, underLAZY_URL_RESOLUTION_ERROR. Only that class degrades; anything else is a backend bug and propagates, because a silent/404/on a page that does route gets indexed.4. Forced URL columns had to learn which fetch they belong to. A
?fields=urlquery strips the columns lazy needs, so the input serializers force them back into the fetch and the output mappers strip them from the response. Lazy being authoritative is the first time that mechanism runs, and it exposed two gaps.forcedUrlColumnswas a barestring[], so a posts request's forced columns were applied to its included tags and authors as well — and those resources shareslug/statuswith posts, so each mapper was deleting the other's fields off data the caller had asked for. It now carries itsrouterTypeand each mapper strips only on a match. That subsumes the narrowertagFramecopy 🐛 Fixed a post URL becoming /404/ when the API is asked for specific fields #29797 added on main, which covered tags but not authors, so the guard is removed here. Dropping its{...frame}spread also removes a trap: the spread returned a plain object, so aFramemethod called anywhere in the tag path would have thrown for nested tags only.mappers/users.jshad no strip block at all, so/users/?fields=urlleaked the forcedslug. Added.A read is exempt from stripping the fields it was looked up by:
findOneforges the model with them before the fetch, soposts/slug/:slug/?fields=title,urlis served its slug today and still is. Those columns are still selected, because the lookup matches case-insensitively — the forged value can differ in case from the stored one, and the URL has to be built from what is stored. #29797 established this rule forid; it is generalised here and the two mechanisms fold into one.Router-lifecycle throws report under their own
LAZY_URL_HOOK_ERRORrather than the compare code, so a swallowedreset()during a routes reload is not filed as comparison noise.Why so many test files changed
Because the snapshots were asserting the wrong thing, and this is the first time the suite has ever run lazy.
boot.jspassesurlCache: !frontend, so backend-only boots served URLs fromtest/utils/fixtures/urls/urls.json— a 22-entry file from 2021 that only the eager service can read. The snapshots were therefore pinning that file's contents, not URL generation. Two examples:admin/usersasserted every author exceptghostandjoe-bloggshad a URL of/404/.legacy/authenticationasserted that a user whose slug istesthad Joe Bloggs' URL.Every URL diff in this PR is
/404/→ a real URL. None go the other way (83/404/lines removed from tests, and none added as an expected URL value — the 10 additions are comments, test titles and the new facade unit tests). The suite now asserts real URL generation.One subset is a real behaviour change, not a fixture artifact
Empty tags and authors. Eager applies a
shouldHavePostsgate (services/url/config.js:113,142) so a tag or author with no published posts is left out of its URL map and resolves to/404/. Lazy has no cheap way to run that check and returns the real URL.This was decided in HKG-1920 and accepted — the empty archive still hard-404s to visitors and crawlers, because that 404 lives in the routing controllers, not the URL service. The only visible change is enumeration:
{{#get "tags"}}and the Content API's/tags/and/authors/urlfield now carry the tag's own URL instead of a literal/404/href. Both land on a 404 page; the new value is at least correct.Affected assertions are
admin/users.test.js(every author routable, including suspended) andadmin/tags.test.js(acount.posts === 0tag), both asserting it deliberately rather than by snapshot refresh. The_isExpectedDivergencebranch that suppressed this during compare is now dead and its "still open" comment is corrected to cite HKG-1920; deleting the branch belongs with the eager removal.Note the sitemap is unaffected:
routable-resources.jskeeps theshouldHavePostsjoin, which is the follow-up HKG-1920 called for.Two further things fell out of running lazy for the first time:
url-service-utils.js, next to the eager reset already there.comments-lazy-url-parity.test.jshand-built a compare-mode facade, whosehasFinished()delegates to eager — which now never gets routers, so it 503'd on its routes upload. The compare scaffolding is gone; the regression assertion it existed for is intact.Testing
Test-first throughout. Full DB-backed sweep — e2e, e2e-api, e2e-isolated, legacy, integration — 2827 passed across 218 files, plus the unit suite, lint and types.
Rollback
Revert and deploy. There is no config-level rollback: the gate that would have provided one is what this PR removes, deliberately, so that the switch cannot ship untested.
Known follow-ups (not in scope)
isLazy()branches insite-map-managerwere dead code until now. The eager per-URLurl.added/url.removedfeed goes silent, leavingsite.changedas the only signal;users.editiscacheInvalidate: false, so an author slug rename no longer invalidates the sitemap.bridge.js:128starts the eager queue on reload, now a no-op that self-reschedules forever. Should go with the eager deletion.lazyRouting:test-title prefixes across ~20 unit tests now name a flag that no longer exists._isExpectedDivergenceand the whole compare apparatus (_compare,_compareAsync,_reportMismatch,skipComparison) are unreachable withcomparehard-coded false — they go with the eager deletion.url-serviceboot metric and its "URL Service ready in Xms" log stop being emitted. No correctness impact (nothing reads eager'sfinishedany more) but worth knowing before deploy day so a flat metric isn't mistaken for a monitoring outage.