@@ -1174,6 +1174,304 @@ export function createDbAdapter({ pg }: Pick<AppComponents, 'pg'>): IDbComponent
11741174 } )
11751175 }
11761176
1177+ /**
1178+ * Internal: Updates the bundle status for a specific platform within a transaction.
1179+ * Sets the simplified status (PENDING, COMPLETE, FAILED) for either the assets or lods bundle type.
1180+ *
1181+ * @param id - The entity ID to update
1182+ * @param platform - The platform to update (e.g., 'windows', 'mac', 'webgl')
1183+ * @param lods - Whether to update the LODs bundle (true) or assets bundle (false)
1184+ * @param status - The new simplified status for the bundle
1185+ * @param executor - The transaction client to use for the query
1186+ * @returns The updated registry entity, or null if the entity was not found
1187+ */
1188+ async function _upsertRegistryBundle (
1189+ id : string ,
1190+ platform : string ,
1191+ lods : boolean ,
1192+ status : Registry . SimplifiedStatus ,
1193+ executor : QueryExecutor
1194+ ) : Promise < Registry . DbEntity | null > {
1195+ const bundleType = lods ? 'lods' : 'assets'
1196+
1197+ const query : SQLStatement = SQL `
1198+ UPDATE registries
1199+ SET
1200+ bundles = jsonb_set(
1201+ registries.bundles,
1202+ ARRAY[${ bundleType } ::text, ${ platform } ::text],
1203+ to_jsonb(${ status } ::text)
1204+ )
1205+ WHERE LOWER(registries.id) = ${ id . toLocaleLowerCase ( ) }
1206+ RETURNING *
1207+ `
1208+
1209+ const result = await executor . query < Registry . DbEntity > ( query )
1210+ return result . rows [ 0 ] || null
1211+ }
1212+
1213+ /**
1214+ * Internal: Updates the version and build date for a specific platform's asset bundle within a transaction.
1215+ *
1216+ * @param id - The entity ID to update
1217+ * @param platform - The platform to update (e.g., 'windows', 'mac', 'webgl')
1218+ * @param version - The asset bundle version string
1219+ * @param buildDate - The ISO date string of when the bundle was built
1220+ * @param executor - The transaction client to use for the query
1221+ * @returns The updated registry entity, or null if the entity was not found
1222+ */
1223+ async function _updateRegistryVersionWithBuildDate (
1224+ id : string ,
1225+ platform : string ,
1226+ version : string ,
1227+ buildDate : string ,
1228+ executor : QueryExecutor
1229+ ) : Promise < Registry . DbEntity | null > {
1230+ const bundleType = 'assets'
1231+ const versionData = { version, buildDate }
1232+ const query : SQLStatement = SQL `
1233+ UPDATE registries
1234+ SET
1235+ versions = jsonb_set(
1236+ COALESCE(registries.versions, '{}'::jsonb),
1237+ ARRAY[${ bundleType } ::text, ${ platform } ::text],
1238+ to_jsonb(${ versionData } ::jsonb)
1239+ )
1240+ WHERE LOWER(registries.id) = ${ id . toLocaleLowerCase ( ) }
1241+ RETURNING *
1242+ `
1243+
1244+ const result = await executor . query < Registry . DbEntity > ( query )
1245+ return result . rows [ 0 ] || null
1246+ }
1247+
1248+ /**
1249+ * Internal: Gets registries related to a given registry by overlapping pointers within a transaction.
1250+ * Filters by world name context (same as the public getRelatedRegistries) and excludes OBSOLETE entities.
1251+ *
1252+ * @param registry - The registry to find related entities for (uses pointers, id, and metadata)
1253+ * @param executor - The transaction client to use for the query
1254+ * @returns Related registries that share pointers within the same context (world or Genesis City)
1255+ */
1256+ async function _getRelatedRegistries (
1257+ registry : Pick < Registry . DbEntity , 'pointers' | 'id' | 'metadata' > ,
1258+ executor : QueryExecutor
1259+ ) : Promise < Registry . PartialDbEntity [ ] > {
1260+ const worldName = ( registry . metadata as any ) ?. worldConfiguration ?. name as string | undefined
1261+ const lowerCasePointers = registry . pointers . map ( ( p ) => p . toLowerCase ( ) )
1262+
1263+ const query : SQLStatement = SQL `
1264+ SELECT
1265+ id, pointers, timestamp, status, bundles, versions
1266+ FROM
1267+ registries
1268+ WHERE
1269+ pointers && ${ lowerCasePointers } ::varchar(255)[]
1270+ AND LOWER(id) != ${ registry . id . toLocaleLowerCase ( ) }
1271+ AND status != ${ Registry . Status . OBSOLETE }
1272+ `
1273+
1274+ if ( worldName ) {
1275+ const normalizedWorldName = worldName . toLowerCase ( )
1276+ query . append ( SQL `
1277+ AND LOWER(metadata->'worldConfiguration'->>'name') = ${ normalizedWorldName }
1278+ ` )
1279+ } else {
1280+ query . append ( SQL `
1281+ AND metadata->'worldConfiguration'->>'name' IS NULL
1282+ ` )
1283+ }
1284+
1285+ query . append ( SQL `
1286+ ORDER BY timestamp DESC
1287+ ` )
1288+
1289+ const result = await executor . query < Registry . PartialDbEntity > ( query )
1290+ return result . rows
1291+ }
1292+
1293+ /**
1294+ * Internal: Inserts or updates a registry entity within a transaction.
1295+ * Uses an upsert (INSERT ... ON CONFLICT DO UPDATE) to handle both new and existing entities.
1296+ * Preserves the existing deployer if the new deployer is empty.
1297+ *
1298+ * @param registry - The full registry entity to insert or update
1299+ * @param executor - The transaction client to use for the query
1300+ * @returns The inserted or updated registry entity
1301+ */
1302+ async function _insertRegistry ( registry : Registry . DbEntity , executor : QueryExecutor ) : Promise < Registry . DbEntity > {
1303+ const query : SQLStatement = SQL `
1304+ INSERT INTO registries (
1305+ id, type, timestamp, deployer, pointers, content, metadata, status, bundles, versions
1306+ )
1307+ VALUES (
1308+ ${ registry . id } ,
1309+ ${ registry . type } ,
1310+ ${ registry . timestamp } ,
1311+ ${ registry . deployer . toLocaleLowerCase ( ) } ,
1312+ ${ registry . pointers } ::varchar(255)[],
1313+ ${ JSON . stringify ( registry . content ) } ::jsonb,
1314+ ${ JSON . stringify ( registry . metadata ) } ::jsonb,
1315+ ${ registry . status } ,
1316+ ${ JSON . stringify ( registry . bundles ) } ::jsonb,
1317+ ${ JSON . stringify ( registry . versions ) } ::jsonb
1318+ )
1319+ ON CONFLICT (id) DO UPDATE
1320+ SET
1321+ type = EXCLUDED.type,
1322+ timestamp = EXCLUDED.timestamp,
1323+ pointers = EXCLUDED.pointers,
1324+ content = EXCLUDED.content,
1325+ metadata = EXCLUDED.metadata,
1326+ status = EXCLUDED.status,
1327+ bundles = EXCLUDED.bundles,
1328+ deployer = CASE
1329+ WHEN EXCLUDED.deployer != '' THEN EXCLUDED.deployer
1330+ ELSE registries.deployer
1331+ END,
1332+ versions = EXCLUDED.versions
1333+ RETURNING
1334+ id,
1335+ type,
1336+ timestamp,
1337+ deployer,
1338+ pointers,
1339+ content,
1340+ metadata,
1341+ status,
1342+ bundles,
1343+ versions
1344+ `
1345+
1346+ const result = await executor . query < Registry . DbEntity > ( query )
1347+ return result . rows [ 0 ]
1348+ }
1349+
1350+ /**
1351+ * Internal: Updates the status of multiple registries by their IDs within a transaction.
1352+ *
1353+ * @param ids - Array of entity IDs to update
1354+ * @param status - The new status to set
1355+ * @param executor - The transaction client to use for the query
1356+ * @returns The updated registry entities
1357+ */
1358+ async function _updateRegistriesStatus (
1359+ ids : string [ ] ,
1360+ status : Registry . Status ,
1361+ executor : QueryExecutor
1362+ ) : Promise < Registry . DbEntity [ ] > {
1363+ const parsedIds = ids . map ( ( id ) => id . toLocaleLowerCase ( ) )
1364+
1365+ const query : SQLStatement = SQL `
1366+ UPDATE registries
1367+ SET status = ${ status }
1368+ WHERE LOWER(id) = ANY(${ parsedIds } ::varchar(255)[])
1369+ RETURNING
1370+ id,
1371+ type,
1372+ timestamp,
1373+ pointers,
1374+ deployer,
1375+ content,
1376+ metadata,
1377+ status,
1378+ bundles,
1379+ versions
1380+ `
1381+
1382+ const result = await executor . query < Registry . DbEntity > ( query )
1383+ return result . rows || null
1384+ }
1385+
1386+ /**
1387+ * Atomically updates a bundle, optionally updates the version, reads the current entity state and
1388+ * related registries, then persists the registry with rotated statuses — all within a single transaction.
1389+ *
1390+ * This prevents race conditions where concurrent texture events for the same entity could result in
1391+ * a stale thread overwriting a correct COMPLETE status back to PENDING, because the status determination
1392+ * always uses the current DB state (after the bundle update) rather than the caller's potentially stale snapshot.
1393+ *
1394+ * The transaction executes the following steps:
1395+ * 1. Updates the bundle status for the specified platform
1396+ * 2. Optionally updates the version and build date
1397+ * 3. Reads related registries (consistent within the transaction)
1398+ * 4. Calls the provided callback to determine the registry status and rotation actions
1399+ * 5. Inserts/updates the registry with the determined status
1400+ * 6. Marks older entities as OBSOLETE
1401+ * 7. Updates the fallback entity's status
1402+ *
1403+ * @param params.bundleUpdate - The bundle status update to apply (entityId, platform, isLods, status)
1404+ * @param params.versionUpdate - Optional version and build date update
1405+ * @param params.determineStatusAndRotate - Callback that receives the current entity and related registries,
1406+ * and returns the status, older entity IDs to mark as OBSOLETE, and an optional fallback update
1407+ * @returns The persisted registry entity with its final status, or null if the entity was not found
1408+ */
1409+ async function persistRegistryInTransaction ( params : {
1410+ bundleUpdate : { entityId : string ; platform : string ; isLods : boolean ; status : Registry . SimplifiedStatus }
1411+ versionUpdate ?: { entityId : string ; platform : string ; version : string ; buildDate : string }
1412+ determineStatusAndRotate : (
1413+ currentEntity : Registry . DbEntity ,
1414+ relatedRegistries : Registry . PartialDbEntity [ ]
1415+ ) => {
1416+ status : Registry . Status
1417+ olderEntityIds : string [ ]
1418+ fallbackUpdate : { id : string ; status : Registry . Status } | null
1419+ }
1420+ } ) : Promise < Registry . DbEntity | null > {
1421+ return pg . withTransaction ( async ( client ) => {
1422+ const { bundleUpdate, versionUpdate, determineStatusAndRotate } = params
1423+
1424+ // 1. Update the bundle status
1425+ let entity = await _upsertRegistryBundle (
1426+ bundleUpdate . entityId ,
1427+ bundleUpdate . platform ,
1428+ bundleUpdate . isLods ,
1429+ bundleUpdate . status ,
1430+ client
1431+ )
1432+
1433+ if ( ! entity ) {
1434+ return null
1435+ }
1436+
1437+ // 2. Optionally update version
1438+ if ( versionUpdate ) {
1439+ entity = await _updateRegistryVersionWithBuildDate (
1440+ versionUpdate . entityId ,
1441+ versionUpdate . platform ,
1442+ versionUpdate . version ,
1443+ versionUpdate . buildDate ,
1444+ client
1445+ )
1446+
1447+ if ( ! entity ) {
1448+ return null
1449+ }
1450+ }
1451+
1452+ // 3. Read related registries (within the same transaction for consistency)
1453+ const relatedRegistries = await _getRelatedRegistries ( entity , client )
1454+
1455+ // 4. Let the caller determine status and rotation
1456+ const { status, olderEntityIds, fallbackUpdate } = determineStatusAndRotate ( entity , relatedRegistries )
1457+
1458+ // 5. Insert/update the registry with the determined status
1459+ const insertedRegistry = await _insertRegistry ( { ...entity , status } , client )
1460+
1461+ // 6. Update older entities
1462+ if ( olderEntityIds . length > 0 ) {
1463+ await _updateRegistriesStatus ( olderEntityIds , Registry . Status . OBSOLETE , client )
1464+ }
1465+
1466+ // 7. Update fallback
1467+ if ( fallbackUpdate ) {
1468+ await _updateRegistriesStatus ( [ fallbackUpdate . id ] , fallbackUpdate . status , client )
1469+ }
1470+
1471+ return insertedRegistry
1472+ } )
1473+ }
1474+
11771475 return {
11781476 insertRegistry,
11791477 updateRegistriesStatus,
@@ -1210,6 +1508,7 @@ export function createDbAdapter({ pg }: Pick<AppComponents, 'pg'>): IDbComponent
12101508 setSpawnCoordinate,
12111509 recalculateSpawnCoordinate,
12121510 undeployWorldScenes,
1213- undeployWorldByName
1511+ undeployWorldByName,
1512+ persistRegistryInTransaction
12141513 }
12151514}
0 commit comments