ProjectClaimManager.ts
1 /* eslint-disable no-console */ 2 /* eslint-disable @typescript-eslint/no-explicit-any */ 3 4 import { promises as fs } from 'fs'; 5 import path from 'path'; 6 import { lock } from 'proper-lockfile'; 7 import { TEST_ADDRESSES } from './ConnectedSession'; 8 import { gqlClient } from './gqlClient'; 9 10 export const ADDRESS_TO_REPO_MAP: Record<string, string> = { 11 [TEST_ADDRESSES[0]]: 'https://github.com/efstajas/drips-test-repo-1', 12 [TEST_ADDRESSES[1]]: 'https://github.com/efstajas/drips-test-repo-2', 13 [TEST_ADDRESSES[2]]: 'https://github.com/efstajas/drips-test-repo-3', 14 [TEST_ADDRESSES[3]]: 'https://github.com/efstajas/drips-test-repo-4', 15 [TEST_ADDRESSES[4]]: 'https://github.com/efstajas/drips-test-repo-5', 16 [TEST_ADDRESSES[5]]: 'https://github.com/efstajas/drips-test-repo-6', 17 }; 18 19 const lockOptions = { 20 retries: 20, 21 retryWait: 1000, 22 realpath: false, 23 }; 24 25 type ClaimState = 'unclaimed' | 'claiming' | 'claimed'; 26 type RepoStates = Record<string, ClaimState>; 27 28 const STATE_FILE_PATH = path.join(process.cwd(), 'test-data', 'project-states.json'); 29 30 class ProjectClaimManager { 31 private static instance: ProjectClaimManager; 32 33 private constructor() { 34 // The constructor is now simpler. State is managed in the file. 35 } 36 37 public static getInstance(): ProjectClaimManager { 38 if (!ProjectClaimManager.instance) { 39 ProjectClaimManager.instance = new ProjectClaimManager(); 40 } 41 return ProjectClaimManager.instance; 42 } 43 44 /** 45 * Reads the state from the JSON file. 46 * Initializes the file if it doesn't exist. 47 */ 48 private async _readState(): Promise<RepoStates> { 49 try { 50 await fs.mkdir(path.dirname(STATE_FILE_PATH), { recursive: true }); 51 const fileContent = await fs.readFile(STATE_FILE_PATH, 'utf-8'); 52 return JSON.parse(fileContent); 53 } catch (error: any) { 54 if (error.code === 'ENOENT') { 55 // File doesn't exist 56 const initialState: RepoStates = {}; 57 Object.values(ADDRESS_TO_REPO_MAP).forEach((repoUrl) => { 58 initialState[repoUrl] = 'unclaimed'; 59 }); 60 await fs.writeFile(STATE_FILE_PATH, JSON.stringify(initialState, null, 2)); 61 return initialState; 62 } 63 throw error; 64 } 65 } 66 67 /** 68 * Writes the given state to the JSON file. 69 */ 70 private async _writeState(state: RepoStates): Promise<void> { 71 await fs.writeFile(STATE_FILE_PATH, JSON.stringify(state, null, 2)); 72 } 73 74 /** 75 * Helper function to delay execution. 76 */ 77 private _delay(ms: number) { 78 return new Promise((resolve) => setTimeout(resolve, ms)); 79 } 80 81 // Your GraphQL check function. 82 private async _checkIfClaimed(repoUrl: string) { 83 const isClaimedQueryResult = await gqlClient.request( 84 `query isClaimed { 85 projectByUrl(url: "${repoUrl}", chains: [LOCALTESTNET]) { 86 chainData { 87 ...on ClaimedProjectData { 88 verificationStatus 89 } 90 ...on UnClaimedProjectData { 91 verificationStatus 92 } 93 } 94 } 95 }`, 96 ); 97 98 return (isClaimedQueryResult as any).projectByUrl.chainData[0].verificationStatus === 'Claimed'; 99 } 100 101 public async getClaimedProject( 102 ownerAddress: string, 103 claimFunction: (repoUrl: string) => Promise<void>, 104 ): Promise<string> { 105 const repoUrl = ADDRESS_TO_REPO_MAP[ownerAddress]; 106 if (!repoUrl) { 107 throw new Error(`No test repository is associated with address: ${ownerAddress}`); 108 } 109 110 // First, check the actual backend state. This can help synchronize the state file. 111 const isAlreadyClaimedOnChain = await this._checkIfClaimed(repoUrl); 112 if (isAlreadyClaimedOnChain) { 113 const release = await lock(STATE_FILE_PATH, lockOptions); 114 const state = await this._readState(); 115 if (state[repoUrl] !== 'claimed') { 116 console.log(`[Manager] Syncing state for ${repoUrl} to 'claimed' based on backend data.`); 117 state[repoUrl] = 'claimed'; 118 await this._writeState(state); 119 } 120 await release(); 121 return repoUrl; 122 } 123 124 // Acquire a lock on the state file to coordinate workers. 125 const release = await lock(STATE_FILE_PATH, lockOptions); 126 const state = await this._readState(); 127 const currentState = state[repoUrl]; 128 129 switch (currentState) { 130 case 'claimed': 131 await release(); 132 console.log(`[Manager] Project ${repoUrl} is claimed (state file). Reusing.`); 133 return repoUrl; 134 135 case 'claiming': 136 await release(); 137 console.log(`[Manager] Project ${repoUrl} is being claimed. Waiting...`); 138 await this._delay(2000); // Wait for 2 seconds before retrying. 139 return this.getClaimedProject(ownerAddress, claimFunction); // Retry. 140 141 case 'unclaimed': 142 console.log(`[Manager] Project ${repoUrl} is unclaimed. Starting claim process.`); 143 state[repoUrl] = 'claiming'; 144 await this._writeState(state); 145 // Release the lock so other workers can check the 'claiming' state. 146 await release(); 147 148 try { 149 // Perform the actual claim operation. 150 await claimFunction(repoUrl); 151 152 // Re-acquire lock to update state to 'claimed'. 153 const finalRelease = await lock(STATE_FILE_PATH, lockOptions); 154 const finalState = await this._readState(); 155 finalState[repoUrl] = 'claimed'; 156 await this._writeState(finalState); 157 await finalRelease(); 158 159 console.log(`[Manager] Successfully claimed ${repoUrl}.`); 160 return repoUrl; 161 } catch (error) { 162 console.error(`[Manager] Failed to claim ${repoUrl}. Resetting state.`, error); 163 // Re-acquire lock to reset state to 'unclaimed'. 164 const errorRelease = await lock(STATE_FILE_PATH, lockOptions); 165 const errorState = await this._readState(); 166 errorState[repoUrl] = 'unclaimed'; 167 await this._writeState(errorState); 168 await errorRelease(); 169 170 throw error; // Rethrow to fail the test. 171 } 172 173 default: 174 await release(); 175 throw new Error(`Unknown claim state for project: ${repoUrl}`); 176 } 177 } 178 } 179 180 export type { ProjectClaimManager }; 181 export const projectClaimManager = ProjectClaimManager.getInstance();