feat(serenity): mirror each Semrush market as a SpaceCat Site#2643
feat(serenity): mirror each Semrush market as a SpaceCat Site#2643rainer-friederich wants to merge 7 commits into
Conversation
|
This PR will trigger a minor release when merged. |
Every Semrush market (project) now gets a resolvable SpaceCat Site on our side, for backwards compatibility and integrations that look a site up by its base URL. Domain model: a brand is a shell with no domain of its own (like its Semrush sub-workspace). Each MARKET has its own primary URL/domain, and that domain maps to a single Site (global sites.base_url uniqueness => at most one Site per domain). A brand whose markets span distinct domains owns several Sites. - ensureMarketSite (site-linkage.js): idempotently ensures a Site per market domain (deliveryType 'other') and links it to the brand via a brand_sites row tagged type='serenity' (the marker names the owning feature, not the provider). Same-org guard; fully best-effort (never fails a live market). - Hooks (subworkspace mode only): brand creation (initial market), activation (activated markets' domain), market creation (that market's domain). Never auto-deleted; market deletion leaves the Site + link in place. - syncBrandSites preserves type='serenity' rows: a market's domain is generally not in brand.urls, so the delete-all-then-reinsert rebuild would otherwise wipe these links on every brand edit. Excludes them from the delete (incl. NULL-type rows) and keeps the marker from being downgraded on re-upsert. - mapDbBrandToV2 excludes type='serenity' rows from urls[] and siteIds: a market's domain is not a brand URL, so these rows stay a pure backend linkage (resolved via sites / brand_sites directly). Brand V2 response is unchanged. - SerenityMarket.domain exposed (per-market) on the markets API + OpenAPI; docs/serenity.md "Site mirroring" section added. Requires the mysticat-data-service migration allowing brand_sites.type= 'serenity' to be deployed first (else the link insert is silently rejected). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Hey @rainer-friederich,
Verdict: Request changes - one blocking concern about brand URL visibility when a brand URL and market domain resolve to the same site.
Changes: mirrors each Semrush market as a SpaceCat Site entity with a brand_sites row tagged type='serenity', preserving it through brand edits and excluding it from the brand V2 response (12 files).
Note: CI checks are currently failing - resolve before merge.
Must fix before merge
- [Important] Brand URL silently vanishes from V2 response when it resolves to the same site as a market domain -
src/support/brands-storage.js:329(details inline) - [Important]
syncBrandSitesdoes not check for error on the protected-rows SELECT - a transient DB failure silently emptiesprotectedSiteIds, causing the re-INSERT to downgrade serenity rows -src/support/brands-storage.js:269(details inline)
Non-blocking (5): minor issues and suggestions
- nit: race window between
syncBrandSites(SELECT then DELETE then INSERT) and concurrentensureMarketSitecould temporarily downgrade a serenity row's type; self-healing on next market write but worth a comment acknowledging the eventual-consistency guarantee -src/support/brands-storage.js:275 - suggestion: validate
brandDomainformat before passing toensureMarketSite(hostname pattern in OpenAPI or runtime check) to prevent junk/internal hostnames from creating Site records -src/support/serenity/site-linkage.js:68 - nit:
ensureMarketSitereturns a non-null siteId when the existing site belongs to another org (no link created), indistinguishable from full success for callers -src/support/serenity/site-linkage.js:85 - nit:
brandDomainFromPayload(brandData)re-derives the domain instead of capturing the earlierbrandDomainin a shared scope -src/controllers/brands.js:1486 - nit: the
.or()filtertype.is.null,type.neq.serenityrelies on PostgREST NULL semantics that are non-obvious; a one-line comment explaining whyis.nullis needed alongsideneqwould help future readers -src/support/brands-storage.js:282
Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 3m 7s | Cost: $8.81 | Commit: 828dfaaf01e79dd36000a2b3d4baf34be2ac50f7
If this code review was useful, please react with 👍. Otherwise, react with 👎.
828dfaa to
f6d2c17
Compare
…tion The activate/createMarket mirror hooks evaluated brand.getOrganizationId() as a call argument, OUTSIDE ensureMarketSite's best-effort try/catch. A missing or throwing accessor therefore 500'd the operation AFTER the Semrush markets were already live upstream — the opposite of the documented best-effort contract. Optional-chain the accessor so the mirror is skipped (not fatal) in that case. Also gives the openapi-contract fake Brand the getOrganizationId accessor the real model exposes (activateSerenityBrand was returning 500 instead of 200). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
- mapDbBrandToV2: surface a serenity (market-mirror) brand_sites row when the brand ALSO lists that domain as a brand URL. Previously the serenity exclusion silently flipped such a brand URL to onboarded:false and dropped it from siteIds the moment a market was created for the same domain. Pure market domains (not in brand_urls) stay excluded. - syncBrandSites: throw on the protected-rows SELECT error instead of swallowing it. A swallowed error left protectedSiteIds empty and let the re-upsert downgrade a serenity row's type, silently unprotecting a market-mirror link. Fail closed, consistent with the existing delete/upsert error handling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the review. Addressed in Must fix before merge:
Non-blocking:
|
There was a problem hiding this comment.
Hey @rainer-friederich,
Verdict: Approve - both prior blocking findings are addressed with tests; remaining observations are non-blocking.
Changes: mirrors each Semrush market as a SpaceCat Site entity with a brand_sites row tagged type='serenity', preserving it through brand edits and excluding it from the brand V2 response (13 files).
Non-blocking (2): minor issues and suggestions
- nit:
ensureMarketSitereturnssiteIdon brand_sites upsert failure, which contradicts the JSDoc ("null when skipped/failed"); no caller depends on this today, but updating the return or the doc would keep the contract honest -src/support/serenity/site-linkage.js:120 - suggestion: the
ensureMarketSitecall increateBrandForOrgsits inside the try/catch that triggers workspace-release compensation; the never-throws contract holds today (outer catch-all in site-linkage.js), but a one-line comment at the call site like "MUST NOT throw - catch below would release a live workspace" would make the invariant explicit for future editors -src/controllers/brands.js:1483
Previously flagged, now resolved
- Brand URL vanishing when it shares a site with a market domain -
mapDbBrandToV2now surfaces a serenity row when the domain is also an explicit brand URL syncBrandSitesprotected-rows SELECT error handling - now throws (fails closed), consistent with delete/upsert pattern- Race-window comment on SELECT/DELETE/INSERT vs concurrent
ensureMarketSite- documented brandDomainFromPayloadre-derivation - lifted to sharedprovisionedBrandDomain- Cross-org
ensureMarketSitereturn value - now returns null when site belongs to another org .or()filter NULL semantics comment - added- Domain normalization via
hostnameFromUrlString- added
Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 0m 4s | Cost: $9.79 | Commit: fc2943dd387c7cf53eb27785efb06a7410bdc443
If this code review was useful, please react with 👍. Otherwise, react with 👎.
…nt invariant Addresses MysticatBot's two non-blocking nits on #2643 (already Approved): - ensureMarketSite now returns the site id ONLY when the brand_sites link is established; null otherwise (no client, or a failed link write — previously it returned the siteId, contradicting the JSDoc and the cross-org path). No caller depends on the value; this just makes the contract honest and consistent. - brands.js: explicit INVARIANT comment that the ensureMarketSite call must not throw (its catch releases the just-provisioned workspace). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Both non-blocking nits addressed in
|
…44.0) The IT harness previously pinned data-service v5.41.0, which predates the brand_sites_type_check migration that allows type='serenity'. Now that v5.44.0 ships that migration, unblock the held seed + assertions: - bump the IT data-service image v5.41.0 -> v5.44.0 - add a delivery_type='other' market-mirror Site (MARKET_SITE_1, ORG_1) and a type='serenity' brand_sites row linking it to BRAND_1 (seed-data/brand-sites.js, registered at seed Level 3 after brands + sites) - assert GET brand (single + list) EXCLUDES the serenity site from urls[]/siteIds - repoint the by-delivery-type "unmatched" case to aem_ams (other is now used) and add positive coverage that delivery_type=other returns the market-mirror site Full postgres IT suite green against v5.44.0 (547 passing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…site-entities # Conflicts: # test/it/postgres/docker-compose.yml
There was a problem hiding this comment.
Hey @rainer-friederich,
Verdict: Approve - both post-approval nits addressed cleanly; no new concerns.
Changes: mirrors each Semrush market as a SpaceCat Site entity with a brand_sites row tagged type='serenity', preserving it through brand edits and excluding it from the brand V2 response (20 files).
Previously flagged, now resolved
ensureMarketSitenow returns null when the link is not established (upsert failure or missing postgrest client), honoring the JSDoc contract that non-null means "linked"- MUST-NOT-throw invariant documented at the
brands.jscall site - IT seed data and end-to-end assertions wired for the serenity market-mirror exclusion (brand GET single + list, delivery-type filter)
- Data-service image bumped to v5.44.0 (required for
brand_sitesCHECK constraint)
Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 0m 27s | Cost: $4.53 | Commit: a4436387bd59bdd94d0208ddd0327824849751ab
If this code review was useful, please react with 👍. Otherwise, react with 👎.
…n hardening Adds a generatePrompts flag (default false) that gates prompt/topic generation independently of project creation, and finalizes the all-or-nothing sub-workspace activation flow. - generatePrompts maps to handleCreateMarketSubworkspace's generateTopics. Project creation is gated on primary-URL presence (US/EN fallback when no market is supplied), NOT on the flag. No URL -> sub-workspace-only activation (bare brand anchored by its sub-workspace). generatePrompts requires a URL (400 otherwise). Models optional when not generating prompts; empty-units projects publish best-effort. Flag rides the pending_semrush_provisioning JSONB - no migration. - activate stays all-or-nothing: a brand flips active only when the sub-workspace, every market, the brand<->workspace link, and every brand_sites serenity mirror succeed; any failure keeps a pending brand pending with a 502. - fix: per-market modelIds were placed in the body arg on activation but read from the options arg, so stashed LLMs were silently dropped. Moved to the options arg. - test: stub ensureMarketSite in the serenity OpenAPI-contract fixture (it became a required activation gate). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1. Abstract
Every Semrush market now gets a resolvable SpaceCat Site mirroring its primary domain, linked to the owning brand via a
brand_sitesrow taggedtype='serenity'.2. Reasoning
A Serenity brand is a domain-less shell (like its Semrush sub-workspace); the real domains live on its markets. Integrations and lookups that resolve a site by its base URL had nothing to find for these brands. Mirroring each market's domain as a Site restores base-URL resolvability and backwards compatibility without changing the brand model.
3. High-level overview of the changes
ensureMarketSiteidempotently ensures one Site per market domain (deliveryTypeother) and links it to the brand with abrand_sitesrow markedtype='serenity'. Globalsites.base_urluniqueness means at most one Site per domain; a brand whose markets span distinct domains owns several Sites.syncBrandSitesnow preservestype='serenity'(and NULL-type) rows so the delete-then-reinsert brand-edit rebuild no longer wipes these links, and a re-upsert cannot downgrade the marker.mapDbBrandToV2excludestype='serenity'rows fromurls[]andsiteIds, so they stay a pure backend linkage and the Brand V2 response is unchanged.domainis exposed on the markets API + OpenAPI;docs/serenity.mdgains a "Site mirroring" section.4. Required information
5. Affected / used mysticat-workspace projects
brand_sites.type='serenity'; the link insert is silently rejected by the CHECK constraint until that migration is deployed.SerenityMarket.domainfield.7. Test plan
(a) Local: exercise the Serenity through-API local e2e (activate brand -> create market -> deactivate) against the local stack and confirm each market domain resolves to a Site and a
brand_sitesrow withtype='serenity'exists for the brand. Run only after the data-service migration is applied to the local DB.(b) Per-env: after the data-service migration deploys to a given env, create or activate a Serenity market and verify (1) a Site exists for the market's domain, (2) a
brand_sitesrow taggedtype='serenity'links it to the brand, (3) the brand V2 response still omits that domain fromurls[]/siteIds, and (4) the markets API returns the marketdomain.8. Deployment & merge order
brand_sites.type='serenity') — depends-on: MUST merge and deploy first; until then the link insert is silently rejected (migration-first rollout).🤖 Generated with Claude Code