77 limit ,
88 offset ,
99 table ,
10+ tsquery ,
1011 values ,
1112} from "decentraland-gatsby/dist/entities/Database/utils"
1213import { oneOf } from "decentraland-gatsby/dist/entities/Schema/utils"
@@ -19,13 +20,13 @@ import {
1920 WorldAttributes ,
2021 WorldListOrderBy ,
2122} from "./types"
23+ import { DEFAULT_WORLD_IMAGE } from "../shared/constants"
2224import {
2325 buildTextsearch ,
2426 buildUpdateFavoritesQuery ,
2527 buildUpdateLikesQuery ,
2628 buildUserInteractionColumns ,
2729 buildUserInteractionJoins ,
28- buildWorldTextSearchRank ,
2930} from "../shared/entityInteractions"
3031import UserFavoriteModel from "../UserFavorite/model"
3132import UserLikesModel from "../UserLikes/model"
@@ -46,7 +47,7 @@ export default class WorldModel extends Model<WorldAttributes> {
4647 static buildLatestPlaceLateralJoin ( worldAlias : string ) : SQLStatement {
4748 const a = SQL . raw ( worldAlias )
4849 return SQL `LEFT JOIN LATERAL (
49- SELECT p.image, p. contact_name, p.contact_email,
50+ SELECT p.contact_name, p.contact_email,
5051 p.creator_address, p.sdk, p.deployed_at
5152 FROM places p
5253 WHERE p.world_id = ${ a } .id AND p.disabled IS FALSE
@@ -97,7 +98,9 @@ export default class WorldModel extends Model<WorldAttributes> {
9798 ) }
9899 ${ conditional (
99100 ! ! options . search ,
100- SQL `AND ${ buildWorldTextSearchRank ( alias , options . search || "" ) } > 0`
101+ SQL `AND ${ a } .textsearch @@ to_tsquery(${ tsquery (
102+ options . search || ""
103+ ) } )`
101104 ) }
102105 ${ conditional (
103106 ( options . world_names ?. length ?? 0 ) > 0 ,
@@ -175,7 +178,7 @@ export default class WorldModel extends Model<WorldAttributes> {
175178 ) : SQLStatement {
176179 const forCount = opts ?. forCount ?? false
177180 const defaultSelectColumns = SQL `w.*
178- , COALESCE(w.image, lp.image ) as image
181+ , COALESCE(w.image, ${ DEFAULT_WORLD_IMAGE } ) as image
179182 , lp.contact_name
180183 , '0,0' as base_position
181184 , true as world
@@ -193,7 +196,9 @@ export default class WorldModel extends Model<WorldAttributes> {
193196 ) }
194197 ${ conditional (
195198 ! forCount && ! ! options . search ,
196- SQL `, ${ buildWorldTextSearchRank ( "w" , options . search || "" ) } as rank`
199+ SQL `, ts_rank_cd(w.textsearch, to_tsquery(${ tsquery (
200+ options . search || ""
201+ ) } )) as rank`
197202 ) }
198203 FROM ${ table ( this ) } w
199204 ${ this . buildLatestPlaceLateralJoin ( "w" ) }
@@ -252,7 +257,7 @@ export default class WorldModel extends Model<WorldAttributes> {
252257 ) : Promise < AggregateWorldAttributes | null > {
253258 const sql = SQL `
254259 SELECT w.*
255- , COALESCE(w.image, lp.image ) as image
260+ , COALESCE(w.image, ${ DEFAULT_WORLD_IMAGE } ) as image
256261 , lp.contact_name
257262 , '0,0' as base_position
258263 ${ conditional (
@@ -274,13 +279,7 @@ export default class WorldModel extends Model<WorldAttributes> {
274279 , 0 as user_visits
275280 , lp.deployed_at
276281 FROM ${ table ( this ) } w
277- LEFT JOIN LATERAL (
278- SELECT p.image, p.contact_name, p.deployed_at
279- FROM places p
280- WHERE p.world_id = w.id AND p.disabled IS FALSE
281- ORDER BY p.deployed_at DESC
282- LIMIT 1
283- ) lp ON true
282+ ${ this . buildLatestPlaceLateralJoin ( "w" ) }
284283 ${ conditional (
285284 ! ! options . user ,
286285 SQL `LEFT JOIN ${ table (
@@ -439,6 +438,8 @@ export default class WorldModel extends Model<WorldAttributes> {
439438 highlighted : world . highlighted ?? false ,
440439 highlighted_image : world . highlighted_image ?? null ,
441440 ranking : world . ranking ?? 0 ,
441+ settings_configured : world . settings_configured ?? false ,
442+ textsearch : undefined ,
442443 likes : world . likes ?? 0 ,
443444 dislikes : world . dislikes ?? 0 ,
444445 favorites : world . favorites ?? 0 ,
@@ -452,22 +453,24 @@ export default class WorldModel extends Model<WorldAttributes> {
452453 }
453454
454455 /**
455- * Insert a world only if it doesn 't already exist.
456- * Uses INSERT ... ON CONFLICT (id) DO NOTHING for atomicity.
457- * Returns the world ID (lowercased world_name) regardless of whether
458- * the insert was performed .
456+ * Insert a world or update its deployment-derived fields if settings haven 't been
457+ * explicitly configured. Uses INSERT ... ON CONFLICT (id) DO UPDATE ... WHERE
458+ * settings_configured = false so that user-configured worlds are never overwritten
459+ * by deployments .
459460 */
460461 static async insertWorldIfNotExists (
461462 world : Partial < WorldAttributes > & { world_name : string }
462463 ) : Promise < string > {
463464 const worldData = this . buildWorldData ( world )
465+ const textsearch = this . textsearch ( worldData )
464466
465467 const sql = SQL `
466468 INSERT INTO ${ table ( this ) } (
467469 "id", "world_name", "title", "description", "image",
468470 "content_rating", "categories", "owner", "show_in_places",
469471 "single_player", "skybox_time", "is_private",
470472 "highlighted", "highlighted_image", "ranking",
473+ "settings_configured", "textsearch",
471474 "likes", "dislikes", "favorites",
472475 "like_rate", "like_score", "disabled", "disabled_at",
473476 "created_at", "updated_at"
@@ -487,6 +490,8 @@ export default class WorldModel extends Model<WorldAttributes> {
487490 ${ worldData . highlighted } ,
488491 ${ worldData . highlighted_image } ,
489492 ${ worldData . ranking } ,
493+ ${ worldData . settings_configured } ,
494+ ${ textsearch } ,
490495 ${ worldData . likes } ,
491496 ${ worldData . dislikes } ,
492497 ${ worldData . favorites } ,
@@ -497,7 +502,15 @@ export default class WorldModel extends Model<WorldAttributes> {
497502 ${ worldData . created_at } ,
498503 ${ worldData . updated_at }
499504 )
500- ON CONFLICT (id) DO NOTHING
505+ ON CONFLICT (id) DO UPDATE SET
506+ title = EXCLUDED.title,
507+ description = EXCLUDED.description,
508+ image = EXCLUDED.image,
509+ content_rating = EXCLUDED.content_rating,
510+ categories = EXCLUDED.categories,
511+ textsearch = EXCLUDED.textsearch,
512+ updated_at = EXCLUDED.updated_at
513+ WHERE worlds.settings_configured = false
501514 `
502515
503516 await this . namedQuery ( "insert_world_if_not_exists" , sql )
@@ -507,13 +520,15 @@ export default class WorldModel extends Model<WorldAttributes> {
507520 static async upsertWorld (
508521 world : Partial < WorldAttributes > & { world_name : string }
509522 ) : Promise < WorldAttributes > {
510- const worldData = this . buildWorldData ( world )
523+ const worldData = this . buildWorldData ( {
524+ ...world ,
525+ settings_configured : true ,
526+ } )
527+ const textsearch = this . textsearch ( worldData )
511528
512- // Fields that can be updated on conflict (excludes id, world_name, likes, etc.)
513529 const updatableFields : ( keyof WorldAttributes ) [ ] = [
514530 "title" ,
515531 "description" ,
516- "image" ,
517532 "content_rating" ,
518533 "categories" ,
519534 "owner" ,
@@ -523,22 +538,120 @@ export default class WorldModel extends Model<WorldAttributes> {
523538 "is_private" ,
524539 ]
525540
526- // Build changes object with only explicitly provided fields
527- // This ensures we don't overwrite existing values with defaults on conflict
528- const changes : Partial < WorldAttributes > = {
529- updated_at : worldData . updated_at ,
530- }
541+ const setClauses : SQLStatement [ ] = [
542+ SQL `"settings_configured" = true` ,
543+ SQL `"textsearch" = ${ textsearch } ` ,
544+ SQL `"image" = EXCLUDED."image"` ,
545+ SQL `"updated_at" = EXCLUDED."updated_at"` ,
546+ ]
547+
531548 for ( const field of updatableFields ) {
532549 if ( world [ field ] !== undefined ) {
533- ; ( changes as Record < string , unknown > ) [ field ] = world [ field ]
550+ setClauses . push ( SQL `" ${ SQL . raw ( field ) } " = EXCLUDED." ${ SQL . raw ( field ) } "` )
534551 }
535552 }
536553
537- // Upsert on id (lowercased world_name) as the conflict target
538- return this . upsert ( worldData , {
539- target : [ "id" ] ,
540- changes,
541- } )
554+ const sql = SQL `
555+ INSERT INTO ${ table ( this ) } (
556+ "id", "world_name", "title", "description", "image",
557+ "content_rating", "categories", "owner", "show_in_places",
558+ "single_player", "skybox_time", "is_private",
559+ "highlighted", "highlighted_image", "ranking",
560+ "settings_configured", "textsearch",
561+ "likes", "dislikes", "favorites",
562+ "like_rate", "like_score", "disabled", "disabled_at",
563+ "created_at", "updated_at"
564+ ) VALUES (
565+ ${ worldData . id } ,
566+ ${ worldData . world_name } ,
567+ ${ worldData . title } ,
568+ ${ worldData . description } ,
569+ ${ worldData . image } ,
570+ ${ worldData . content_rating } ,
571+ ${ worldData . categories } ,
572+ ${ worldData . owner } ,
573+ ${ worldData . show_in_places } ,
574+ ${ worldData . single_player } ,
575+ ${ worldData . skybox_time } ,
576+ ${ worldData . is_private } ,
577+ ${ worldData . highlighted } ,
578+ ${ worldData . highlighted_image } ,
579+ ${ worldData . ranking } ,
580+ ${ worldData . settings_configured } ,
581+ ${ textsearch } ,
582+ ${ worldData . likes } ,
583+ ${ worldData . dislikes } ,
584+ ${ worldData . favorites } ,
585+ ${ worldData . like_rate } ,
586+ ${ worldData . like_score } ,
587+ ${ worldData . disabled } ,
588+ ${ worldData . disabled_at } ,
589+ ${ worldData . created_at } ,
590+ ${ worldData . updated_at }
591+ )
592+ ON CONFLICT (id) DO UPDATE SET
593+ ${ join ( setClauses , SQL `, ` ) }
594+ RETURNING *
595+ `
596+
597+ const results = await this . namedQuery < WorldAttributes > ( "upsert_world" , sql )
598+ return results [ 0 ]
599+ }
600+
601+ /**
602+ * Atomically delete place records for undeployed scenes and refresh the world's
603+ * deployment-derived fields from the next-latest remaining scene. All operations
604+ * happen in a single SQL statement (CTE-based) to prevent inconsistent state.
605+ *
606+ * If settings_configured = true, the world update is skipped (user settings preserved).
607+ * If no remaining places exist, the UPDATE produces no rows (world stays as-is but
608+ * won't appear in queries since they check EXISTS(places)).
609+ */
610+ static async deleteWorldScenesAndRefresh (
611+ worldId : string ,
612+ basePositions : string [ ] ,
613+ eventTimestamp : number
614+ ) : Promise < void > {
615+ const normalizedWorldId = worldId . toLowerCase ( )
616+ const eventDate = new Date ( eventTimestamp )
617+
618+ const sql = SQL `
619+ WITH deleted AS (
620+ DELETE FROM places
621+ WHERE world_id = ${ normalizedWorldId }
622+ AND base_position = ANY(${ basePositions } )
623+ AND deployed_at < ${ eventDate }
624+ RETURNING world_id
625+ ),
626+ latest_remaining AS (
627+ SELECT p.title, p.description, p.image, p.content_rating, p.categories
628+ FROM places p
629+ WHERE p.world_id = ${ normalizedWorldId }
630+ AND p.disabled IS FALSE
631+ AND p.base_position != ALL(${ basePositions } )
632+ ORDER BY p.deployed_at DESC
633+ LIMIT 1
634+ )
635+ UPDATE worlds SET
636+ title = lr.title,
637+ description = lr.description,
638+ image = lr.image,
639+ content_rating = COALESCE(lr.content_rating, worlds.content_rating),
640+ categories = COALESCE(lr.categories, worlds.categories),
641+ textsearch = (
642+ setweight(to_tsvector(coalesce(lr.title, '')), 'A') ||
643+ setweight(to_tsvector(coalesce(worlds.world_name, '')), 'A') ||
644+ setweight(to_tsvector(coalesce(lr.description, '')), 'B') ||
645+ setweight(to_tsvector(coalesce(worlds.owner, '')), 'C')
646+ ),
647+ updated_at = now()
648+ FROM latest_remaining lr
649+ WHERE worlds.id = ${ normalizedWorldId }
650+ AND worlds.settings_configured = false
651+ AND EXISTS (SELECT 1 FROM deleted)
652+ `
653+
654+ await this . namedQuery ( "delete_world_scenes_and_refresh" , sql )
542655 }
543656
544657 static async disableWorld ( worldName : string ) : Promise < void > {
0 commit comments