Skip to content

feat(serenity): mirror each Semrush market as a SpaceCat Site#2643

Open
rainer-friederich wants to merge 7 commits into
mainfrom
feat/semrush-project-site-entities
Open

feat(serenity): mirror each Semrush market as a SpaceCat Site#2643
rainer-friederich wants to merge 7 commits into
mainfrom
feat/semrush-project-site-entities

Conversation

@rainer-friederich

Copy link
Copy Markdown
Contributor

1. Abstract

Every Semrush market now gets a resolvable SpaceCat Site mirroring its primary domain, linked to the owning brand via a brand_sites row tagged type='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

  • Before: a Serenity brand had no Site, so a base-URL lookup of a market's domain resolved to nothing.
  • After: ensureMarketSite idempotently ensures one Site per market domain (deliveryType other) and links it to the brand with a brand_sites row marked type='serenity'. Global sites.base_url uniqueness means at most one Site per domain; a brand whose markets span distinct domains owns several Sites.
  • Wired into the sub-workspace-mode hooks only: brand creation (initial market), activation (activated markets), and market creation (that market). Best-effort and same-org guarded, so it never fails a live market. Sites/links are never auto-deleted; deleting a market leaves them in place.
  • syncBrandSites now preserves type='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.
  • mapDbBrandToV2 excludes type='serenity' rows from urls[] and siteIds, so they stay a pure backend linkage and the Brand V2 response is unchanged.
  • Per-market domain is exposed on the markets API + OpenAPI; docs/serenity.md gains a "Site mirroring" section.

4. Required information

  • Spec: docs/serenity.md "Site mirroring" section (design-of-record, updated in this PR)

5. Affected / used mysticat-workspace projects

  • mysticat-data-service — depends-on: requires the migration allowing brand_sites.type='serenity'; the link insert is silently rejected by the CHECK constraint until that migration is deployed.
  • project-elmo-ui — consumed: surfaces the new per-market SerenityMarket.domain field.

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_sites row with type='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_sites row tagged type='serenity' links it to the brand, (3) the brand V2 response still omits that domain from urls[]/siteIds, and (4) the markets API returns the market domain.

8. Deployment & merge order

  • mysticat-data-service (branch feat/semrush-project-site-entities, migration brand_sites.type='serenity') — depends-on: MUST merge and deploy first; until then the link insert is silently rejected (migration-first rollout).
  • project-elmo-ui PR 2078 — related: the UI consuming the new per-market domain.
  • Order: deploy the data-service migration first, then merge this PR, then the elmo UI change.

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown

This PR will trigger a minor release when merged.

@rainer-friederich rainer-friederich marked this pull request as ready for review June 19, 2026 08:48
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>

@MysticatBot MysticatBot 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.

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

  1. [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)
  2. [Important] syncBrandSites does not check for error on the protected-rows SELECT - a transient DB failure silently empties protectedSiteIds, 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 concurrent ensureMarketSite could 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 brandDomain format before passing to ensureMarketSite (hostname pattern in OpenAPI or runtime check) to prevent junk/internal hostnames from creating Site records - src/support/serenity/site-linkage.js:68
  • nit: ensureMarketSite returns 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 earlier brandDomain in a shared scope - src/controllers/brands.js:1486
  • nit: the .or() filter type.is.null,type.neq.serenity relies on PostgREST NULL semantics that are non-obvious; a one-line comment explaining why is.null is needed alongside neq would 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 👎.

Comment thread src/support/brands-storage.js
Comment thread src/support/brands-storage.js
@MysticatBot MysticatBot added the ai-reviewed Reviewed by AI label Jun 19, 2026
@rainer-friederich rainer-friederich force-pushed the feat/semrush-project-site-entities branch from 828dfaa to f6d2c17 Compare June 19, 2026 09:00
…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

codecov Bot commented Jun 19, 2026

Copy link
Copy Markdown

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>
@rainer-friederich

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Addressed in fc2943dd (the review ran on 828dfaa; several non-blocking items were already fixed in the intervening f6d2c170/a97155ce). Also: the CI failure you noted is fixed in a97155ce (an activate mirror hook evaluated brand.getOrganizationId() outside ensureMarketSite's try/catch → 500; now optional-chained).

Must fix before merge:

  • [Important] Brand URL vanishes when it shares a site with a market domain (brands-storage.js) — fixed. mapDbBrandToV2 now surfaces a type='serenity' row when the brand ALSO lists that exact domain as a brand URL, so it keeps onboarded:true/siteId. Pure market domains (not in brand_urls) stay excluded. New test: "surfaces a Serenity row when its domain is ALSO an explicit brand URL".
  • [Important] syncBrandSites ignores the protected-rows SELECT error — fixed. Now throws (fails closed), consistent with the delete/upsert error pattern; a swallowed error would empty protectedSiteIds and downgrade serenity rows. Replaced the earlier "silently proceeds" test with a fail-closed one.

Non-blocking:

  • nit: race-window comment (brands-storage.js) — done (added in f6d2c170): the SELECT→DELETE→INSERT vs concurrent ensureMarketSite downgrade window is now documented as doubly-rare + self-healing on next market write.
  • suggestion: validate brandDomain before ensureMarketSite — partially done / rest declined. ensureMarketSite now normalizes via hostnameFromUrlString (added in f6d2c170), so unparseable/junk input is skipped. A stage/internal-hostname blocklist is declined as out of scope here: the market domain is provisioned to Semrush upstream, which is its source of truth.
  • nit: cross-org ensureMarketSite returns a non-null siteId — done (f6d2c170): returns null now when the site belongs to another org (no link created).
  • nit: brandDomainFromPayload re-derives the domain — done (f6d2c170): lifted to a shared provisionedBrandDomain.
  • nit: .or() filter needs a comment on the is.null+neq semantics — done: the comment ("a bare .neq would skip NULLs; type IS DISTINCT FROM 'serenity'") is in place.

@MysticatBot MysticatBot 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.

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: ensureMarketSite returns siteId on 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 ensureMarketSite call in createBrandForOrg sits 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 - mapDbBrandToV2 now surfaces a serenity row when the domain is also an explicit brand URL
  • syncBrandSites protected-rows SELECT error handling - now throws (fails closed), consistent with delete/upsert pattern
  • Race-window comment on SELECT/DELETE/INSERT vs concurrent ensureMarketSite - documented
  • brandDomainFromPayload re-derivation - lifted to shared provisionedBrandDomain
  • Cross-org ensureMarketSite return 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>
@rainer-friederich

Copy link
Copy Markdown
Contributor Author

Both non-blocking nits addressed in 27b02fef (leaving your approval standing — doc tweak + return-value honesty fix, no logic rework):

  • ensureMarketSite now returns the site id only when the link is established; null otherwise (no client / failed link write). Tests updated.
  • Added the explicit MUST-NOT-throw invariant comment at the brands.js call site.

…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

@MysticatBot MysticatBot 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.

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

  • ensureMarketSite now 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.js call 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_sites CHECK 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-reviewed Reviewed by AI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants