import 'dotenv/config'; import { z } from 'zod'; import { ParseRestClient, ParseRestError } from '../src/db/parse-rest.client.js'; import { ensureListingParseSchemas, VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js'; import type { ListingScoreJobItem, ListingScoreResult, ListingVersion } from '../src/modules/listing-ai/domain.js'; const LEGACY_CLASS = 'VocListingScoreResult'; interface Stored { objectId: string; workspaceId: string; productId?: string; payload: T; sourceHash?: string; isCurrent?: boolean; scoreKind?: string; aiStatus?: string; scoredAt?: unknown } function slot(score: ListingScoreResult): 'rule_precheck' | 'formal_ai' { return score.scoreKind === 'hybrid_ai' ? 'formal_ai' : 'rule_precheck'; } async function retry(operation:()=>Promise):Promise{let last:unknown;for(let attempt=1;attempt<=8;attempt+=1){try{return await operation();}catch(error){last=error;if(!(error instanceof ParseRestError&&[403,404,502,503,504].includes(error.status))||attempt===8)throw error;await new Promise((resolve)=>setTimeout(resolve,attempt*250));}}throw last;} async function findAllForMigration(client: ParseRestClient, className: string): Promise> { const expected=await retry(()=>client.count(className,{}));if(expected>10_000)throw new Error(`${className}_migration_limit_exceeded=${expected}`);const page=await retry(()=>client.find(className,{limit:10_000}));if(page.results.length(values:T[],worker:(value:T)=>Promise,concurrency=5):Promise{let cursor=0;await Promise.all(Array.from({length:Math.min(concurrency,values.length)},async()=>{for(;;){const index=cursor++;if(index>=values.length)return;await worker(values[index]!);}}));} async function deleteLegacyRow(client:ParseRestClient,objectId:string):Promise{try{await retry(()=>client.delete(LEGACY_CLASS,objectId));}catch(error){if(!(error instanceof ParseRestError&&error.status===404))throw error;}} async function main() { const env = z.object({ PARSE_SERVER_URL: z.url(), PARSE_APP_ID: z.string().min(1), PARSE_MASTER_KEY: z.string().min(1), SAAS_DEFAULT_WORKSPACE_ID: z.string().default('demashi') }).parse(process.env); const client = new ParseRestClient({ serverUrl: env.PARSE_SERVER_URL, appId: env.PARSE_APP_ID, masterKey: env.PARSE_MASTER_KEY }); const workspaceId = env.SAAS_DEFAULT_WORKSPACE_ID; let schemaApiAvailable = true; try { await ensureListingParseSchemas(client); } catch (error) { if (!(error instanceof ParseRestError && error.status === 404)) throw error; schemaApiAvailable = false; } const legacy = await findAllForMigration>(client, LEGACY_CLASS); const selected = new Map>(); for (const row of legacy) { const score = row.payload; if (score.aiStatus === 'failed' || score.aiStatus === 'budget_exceeded') continue; const rowWorkspaceId = row.workspaceId || score.workspaceId; const key = `${rowWorkspaceId}|${score.productId}|${slot(score)}`; const current = selected.get(key); if (!current || score.createdAt > current.payload.createdAt) selected.set(key, row); } const currentRows = await findAllForMigration & { naturalKey?: string }>(client, VOC_PARSE_CLASSES.listingCurrentScore); const currentByNaturalKey = new Map(currentRows.map((row) => [row.naturalKey, row])); const currentRequests = [...selected.values()].map((row) => { const score = row.payload; const currentSlot = slot(score); const rowWorkspaceId = row.workspaceId || score.workspaceId; const naturalKey = `${rowWorkspaceId}|${score.productId}|${currentSlot}`; const body = { publicId: score.id, naturalKey, workspaceId: rowWorkspaceId, productId: score.productId, slot: currentSlot, sourceHash: score.sourceHash, rubricVersion: score.rubricVersion, overallScore: score.overallScore, knownOverallScore: score.knownOverallScore, knownOverallMaxScore: score.knownOverallMaxScore, scoreKind: score.scoreKind ?? 'rules', aiStatus: score.aiStatus, complianceStatus: score.compliance?.status ?? 'normal', executionKey: score.executionKey ?? '', inputFingerprint: score.inputFingerprint ?? '', payload: score, scoredAt: { __type: 'Date', iso: score.createdAt } }; return {existing:currentByNaturalKey.get(naturalKey),body}; }); await runConcurrent(currentRequests,(request)=>retry(()=>request.existing?client.update(VOC_PARSE_CLASSES.listingCurrentScore,request.existing.objectId,request.body):client.create(VOC_PARSE_CLASSES.listingCurrentScore,request.body))); const items = await findAllForMigration>(client, VOC_PARSE_CLASSES.listingScoreItem); const itemsToClean=items.filter((row)=>Object.prototype.hasOwnProperty.call(row.payload,'scoreResultId')); await runConcurrent(itemsToClean,(row)=>{const {scoreResultId:_removed,...payload}=row.payload as ListingScoreJobItem&{scoreResultId?:string|null};return retry(()=>client.update(VOC_PARSE_CLASSES.listingScoreItem,row.objectId,{payload}));}); const versions = await findAllForMigration>(client, VOC_PARSE_CLASSES.listingVersion); const versionsToClean=versions.filter((row)=>Object.prototype.hasOwnProperty.call(row.payload,'baseScoreResultId')); await runConcurrent(versionsToClean,(row)=>{const {baseScoreResultId:_removed,...payload}=row.payload as ListingVersion&{baseScoreResultId?:string|null};return retry(()=>client.update(VOC_PARSE_CLASSES.listingVersion,row.objectId,{payload}));}); await runConcurrent(legacy,(row)=>deleteLegacyRow(client,row.objectId),10); if (schemaApiAvailable) { const schemas = new Set((await client.schemas()).map((item) => item.className)); if (schemas.has(LEGACY_CLASS)) await client.deleteSchema(LEGACY_CLASS); const remainingSchemas = new Set((await client.schemas()).map((item) => item.className)); if (remainingSchemas.has(LEGACY_CLASS)) throw new Error('legacy_listing_score_schema_still_exists'); } const legacyRowsRemaining = schemaApiAvailable ? 0 : await retry(()=>client.count(LEGACY_CLASS, {})); if (legacyRowsRemaining !== 0) throw new Error(`legacy_listing_score_rows_remaining=${legacyRowsRemaining}`); console.log(JSON.stringify({ defaultWorkspaceId: workspaceId, legacyDeleted: legacy.length, currentScores: selected.size, jobItemReferencesCleaned: itemsToClean.length, versionReferencesCleaned: versionsToClean.length, legacyRowsRemaining, legacySchemaDeleted: schemaApiAvailable, schemaApiAvailable }, null, 2)); } await main();