Skip to content

Changed the URL service to answer from lazy everywhere - #29799

Merged
vershwal merged 7 commits into
mainfrom
princi-hkg-1823-switch-urlservicefacade-to-return-lazy2
Aug 6, 2026
Merged

Changed the URL service to answer from lazy everywhere#29799
vershwal merged 7 commits into
mainfrom
princi-hkg-1823-switch-urlservicefacade-to-return-lazy2

Conversation

@vershwal

@vershwal vershwal commented Aug 6, 2026

Copy link
Copy Markdown
Member

ref https://linear.app/ghost/issue/HKG-1823/

Makes LazyUrlService the only URL service.

What changes

UrlServiceFacade.isLazy() is !!lazyUrlService && !compare, so compare: false is the switch. The config.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/. RouterManager itself is untouched: when there is no site app, boot calls the existing routerManager.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 of queue.register, so eager's init queue never emits ended and its finished flag stays false forever. dynamic-routing-service polled exactly that — every routes.yaml upload 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, under LAZY_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=url query 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.

  • forcedUrlColumns was a bare string[], so a posts request's forced columns were applied to its included tags and authors as well — and those resources share slug/status with posts, so each mapper was deleting the other's fields off data the caller had asked for. It now carries its routerType and each mapper strips only on a match. That subsumes the narrower tagFrame copy 🐛 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 a Frame method called anywhere in the tag path would have thrown for nested tags only.
  • mappers/users.js had no strip block at all, so /users/?fields=url leaked the forced slug. Added.

A read is exempt from stripping the fields it was looked up by: findOne forges the model with them before the fetch, so posts/slug/:slug/?fields=title,url is 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 for id; it is generalised here and the two mechanisms fold into one.

Router-lifecycle throws report under their own LAZY_URL_HOOK_ERROR rather than the compare code, so a swallowed reset() 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.js passes urlCache: !frontend, so backend-only boots served URLs from test/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/users asserted every author except ghost and joe-bloggs had a URL of /404/.
  • legacy/authentication asserted that a user whose slug is test had 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 shouldHavePosts gate (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/ url field 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) and admin/tags.test.js (a count.posts === 0 tag), both asserting it deliberately rather than by snapshot refresh. The _isExpectedDivergence branch 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.js keeps the shouldHavePosts join, which is the follow-up HKG-1920 called for.

Two further things fell out of running lazy for the first time:

  • The harness never reset the lazy backend between boots, so router configs accumulated across the ~140 boots in a process. Fixed in url-service-utils.js, next to the eager reset already there.
  • comments-lazy-url-parity.test.js hand-built a compare-mode facade, whose hasFinished() 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)

  • Sitemap invalidation (HKG-1958). The isLazy() branches in site-map-manager were dead code until now. The eager per-URL url.added/url.removed feed goes silent, leaving site.changed as the only signal; users.edit is cacheInvalidate: false, so an author slug rename no longer invalidates the sitemap.
  • bridge.js:128 starts 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.
  • _isExpectedDivergence and the whole compare apparatus (_compare, _compareAsync, _reportMismatch, skipComparison) are unreachable with compare hard-coded false — they go with the eager deletion.
  • Eager's init queue never terminates now that it gets no generators, so the url-service boot metric and its "URL Service ready in Xms" log stop being emitted. No correctness impact (nothing reads eager's finished any more) but worth knowing before deploy day so a flat metric isn't mistaken for a monitoring outage.

@nx-cloud

nx-cloud Bot commented Aug 6, 2026

Copy link
Copy Markdown

🤖 Nx Cloud AI Fix

Ensure the fix-ci command is configured to always run in your CI pipeline to get automatic fixes in future runs. For more information, please see https://nx.dev/ci/features/self-healing-ci


View your CI Pipeline Execution ↗ for commit d54c0ef

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

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The 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 urlFor.

Possibly related PRs

Suggested labels: migration

Suggested reviewers: allouis

🚥 Pre-merge checks | ✅ 2 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR changes URL-service behavior, but the linked issue requires an image-upload drop zone for markdown preview syntax. Implement and test markdown preview handling for !image[] variants, including alt text, optional URL reuse, newline placement, and adjacent-content behavior.
Out of Scope Changes check ⚠️ Warning The URL-service, routing, serializer, and test changes are unrelated to the linked markdown image-upload preview issue. Limit this PR to the linked markdown preview behavior, or link the correct URL-service issue and move these changes to that scope.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: using the lazy URL service for reads.
Description check ✅ Passed The description accurately explains the lazy URL service changes and related testing.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch princi-hkg-1823-switch-urlservicefacade-to-return-lazy2

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.js

ast-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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 LazyUrlService always constructed and ensure UrlServiceFacade answers 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.

Comment thread ghost/core/test/utils/url-service-utils.js

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Preserve the subdirectory in 404 URLs.

notFoundUrl() is the shared fallback for unknown and filtered resources, so the withSubdirectory branch 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

📥 Commits

Reviewing files that changed from the base of the PR and between 4518c42 and 3c47c0d.

⛔ Files ignored due to path filters (12)
  • ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/admin/__snapshots__/pages.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/admin/__snapshots__/post-analytics-export.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/admin/__snapshots__/search-index.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/admin/__snapshots__/users.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/content/__snapshots__/authors.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/content/__snapshots__/posts.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/content/__snapshots__/search-index.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-webhooks/__snapshots__/pages.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/integration/services/email-service/__snapshots__/cards.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/legacy/api/admin/__snapshots__/authentication.test.js.snap is excluded by !**/*.snap
📒 Files selected for processing (22)
  • ghost/core/core/boot.js
  • ghost/core/core/server/api/endpoints/utils/serializers/input/utils/url.js
  • ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js
  • ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/tags.js
  • ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/users.js
  • ghost/core/core/server/services/route-settings/dynamic-routing-service.js
  • ghost/core/core/server/services/url/index.js
  • ghost/core/core/server/services/url/lazy-url-service.ts
  • ghost/core/core/server/services/url/url-service-facade.ts
  • ghost/core/test/e2e-api/admin/tags.test.js
  • ghost/core/test/e2e-api/admin/users.test.js
  • ghost/core/test/e2e-api/members-comments/comments-lazy-url-parity.test.js
  • ghost/core/test/e2e-api/members/create-stripe-checkout-session.test.js
  • ghost/core/test/e2e-api/members/donation-checkout-session.test.js
  • ghost/core/test/e2e-api/members/webhooks.test.js
  • ghost/core/test/unit/api/canary/utils/serializers/input/utils/url.test.js
  • ghost/core/test/unit/api/canary/utils/serializers/output/mapper.test.js
  • ghost/core/test/unit/frontend/helpers/ghost-head.test.js
  • ghost/core/test/unit/frontend/helpers/url.test.js
  • ghost/core/test/unit/server/services/route-settings/dynamic-routing-service.test.ts
  • ghost/core/test/unit/server/services/url/url-service-facade.test.js
  • ghost/core/test/utils/url-service-utils.js

Comment thread ghost/core/core/server/services/url/url-service-facade.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.reset ends 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 calls urlService.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 via resetGenerators).

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.

@vershwal
vershwal force-pushed the princi-hkg-1823-switch-urlservicefacade-to-return-lazy2 branch from 8f173b9 to a2d7268 Compare August 6, 2026 08:57
@vershwal
vershwal requested a lite review from Copilot August 6, 2026 08:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (via core/frontend/web/routes.jsrouting.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

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 67.87879% with 53 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.54%. Comparing base (80b3472) to head (5e4f698).

Files with missing lines Patch % Lines
...ore/core/server/services/url/url-service-facade.ts 41.89% 43 Missing ⚠️
...ndpoints/utils/serializers/output/mappers/users.js 37.50% 5 Missing ⚠️
...api/endpoints/utils/serializers/input/utils/url.js 82.35% 3 Missing ⚠️
...services/route-settings/dynamic-routing-service.js 80.00% 1 Missing ⚠️
.../core/core/server/services/url/lazy-url-service.ts 94.44% 1 Missing ⚠️
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     
Flag Coverage Δ
e2e-tests 77.66% <67.87%> (-0.07%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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
@vershwal
vershwal force-pushed the princi-hkg-1823-switch-urlservicefacade-to-return-lazy2 branch from d54c0ef to 5e4f698 Compare August 6, 2026 09:51
@vershwal
vershwal requested a lite review from Copilot August 6, 2026 09:51
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 37 out of 37 changed files in this pull request and generated no new comments.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
ghost/core/test/unit/api/canary/utils/serializers/input/utils/url.test.js (1)

111-120: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Assert the forced-column metadata for an ID lookup.

This test checks that id remains in frame.options.columns, but it does not check that id is absent from frame.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

📥 Commits

Reviewing files that changed from the base of the PR and between 80b3472 and 5e4f698.

⛔ Files ignored due to path filters (12)
  • ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/admin/__snapshots__/pages.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/admin/__snapshots__/post-analytics-export.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/admin/__snapshots__/search-index.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/admin/__snapshots__/users.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/content/__snapshots__/authors.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/content/__snapshots__/posts.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-api/content/__snapshots__/search-index.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/e2e-webhooks/__snapshots__/pages.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/integration/services/email-service/__snapshots__/cards.test.js.snap is excluded by !**/*.snap
  • ghost/core/test/legacy/api/admin/__snapshots__/authentication.test.js.snap is excluded by !**/*.snap
📒 Files selected for processing (25)
  • ghost/core/core/boot.js
  • ghost/core/core/bridge.js
  • ghost/core/core/server/api/endpoints/utils/serializers/input/utils/url.js
  • ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js
  • ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/tags.js
  • ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/users.js
  • ghost/core/core/server/services/route-settings/dynamic-routing-service.js
  • ghost/core/core/server/services/url/index.js
  • ghost/core/core/server/services/url/lazy-url-service.ts
  • ghost/core/core/server/services/url/url-service-facade.ts
  • ghost/core/test/e2e-api/admin/tags.test.js
  • ghost/core/test/e2e-api/admin/users.test.js
  • ghost/core/test/e2e-api/members-comments/comments-lazy-url-parity.test.js
  • ghost/core/test/e2e-api/members/create-stripe-checkout-session.test.js
  • ghost/core/test/e2e-api/members/donation-checkout-session.test.js
  • ghost/core/test/e2e-api/members/webhooks.test.js
  • ghost/core/test/unit/api/canary/utils/serializers/input/utils/url.test.js
  • ghost/core/test/unit/api/canary/utils/serializers/output/mapper.test.js
  • ghost/core/test/unit/bridge.test.js
  • ghost/core/test/unit/frontend/helpers/ghost-head.test.js
  • ghost/core/test/unit/frontend/helpers/url.test.js
  • ghost/core/test/unit/server/services/route-settings/dynamic-routing-service.test.ts
  • ghost/core/test/unit/server/services/url/lazy-url-service.test.js
  • ghost/core/test/unit/server/services/url/url-service-facade.test.js
  • ghost/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

@vershwal
vershwal merged commit 88d9b5b into main Aug 6, 2026
53 checks passed
@vershwal
vershwal deleted the princi-hkg-1823-switch-urlservicefacade-to-return-lazy2 branch August 6, 2026 10:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants