migrate-listing-current-scores.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
  1. import 'dotenv/config';
  2. import { z } from 'zod';
  3. import { ParseRestClient, ParseRestError } from '../src/db/parse-rest.client.js';
  4. import { ensureListingParseSchemas, VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
  5. import type { ListingScoreJobItem, ListingScoreResult, ListingVersion } from '../src/modules/listing-ai/domain.js';
  6. const LEGACY_CLASS = 'VocListingScoreResult';
  7. interface Stored<T> { objectId: string; workspaceId: string; productId?: string; payload: T; sourceHash?: string; isCurrent?: boolean; scoreKind?: string; aiStatus?: string; scoredAt?: unknown }
  8. function slot(score: ListingScoreResult): 'rule_precheck' | 'formal_ai' { return score.scoreKind === 'hybrid_ai' ? 'formal_ai' : 'rule_precheck'; }
  9. async function retry<T>(operation:()=>Promise<T>):Promise<T>{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;}
  10. async function findAllForMigration<T>(client: ParseRestClient, className: string): Promise<Array<T & { objectId: string }>> { 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<T>(className,{limit:10_000}));if(page.results.length<expected)throw new Error(`${className}_migration_read_incomplete expectedAtLeast=${expected} actual=${page.results.length}`);return page.results; }
  11. async function runConcurrent<T>(values:T[],worker:(value:T)=>Promise<unknown>,concurrency=5):Promise<void>{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]!);}}));}
  12. async function deleteLegacyRow(client:ParseRestClient,objectId:string):Promise<void>{try{await retry(()=>client.delete(LEGACY_CLASS,objectId));}catch(error){if(!(error instanceof ParseRestError&&error.status===404))throw error;}}
  13. async function main() {
  14. 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);
  15. const client = new ParseRestClient({ serverUrl: env.PARSE_SERVER_URL, appId: env.PARSE_APP_ID, masterKey: env.PARSE_MASTER_KEY });
  16. const workspaceId = env.SAAS_DEFAULT_WORKSPACE_ID;
  17. let schemaApiAvailable = true;
  18. try { await ensureListingParseSchemas(client); } catch (error) { if (!(error instanceof ParseRestError && error.status === 404)) throw error; schemaApiAvailable = false; }
  19. const legacy = await findAllForMigration<Stored<ListingScoreResult>>(client, LEGACY_CLASS);
  20. const selected = new Map<string, Stored<ListingScoreResult>>();
  21. for (const row of legacy) {
  22. const score = row.payload;
  23. if (score.aiStatus === 'failed' || score.aiStatus === 'budget_exceeded') continue;
  24. const rowWorkspaceId = row.workspaceId || score.workspaceId;
  25. const key = `${rowWorkspaceId}|${score.productId}|${slot(score)}`;
  26. const current = selected.get(key);
  27. if (!current || score.createdAt > current.payload.createdAt) selected.set(key, row);
  28. }
  29. const currentRows = await findAllForMigration<Stored<ListingScoreResult> & { naturalKey?: string }>(client, VOC_PARSE_CLASSES.listingCurrentScore);
  30. const currentByNaturalKey = new Map(currentRows.map((row) => [row.naturalKey, row]));
  31. 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}; });
  32. 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)));
  33. const items = await findAllForMigration<Stored<ListingScoreJobItem>>(client, VOC_PARSE_CLASSES.listingScoreItem);
  34. const itemsToClean=items.filter((row)=>Object.prototype.hasOwnProperty.call(row.payload,'scoreResultId'));
  35. 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}));});
  36. const versions = await findAllForMigration<Stored<ListingVersion>>(client, VOC_PARSE_CLASSES.listingVersion);
  37. const versionsToClean=versions.filter((row)=>Object.prototype.hasOwnProperty.call(row.payload,'baseScoreResultId'));
  38. 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}));});
  39. await runConcurrent(legacy,(row)=>deleteLegacyRow(client,row.objectId),10);
  40. if (schemaApiAvailable) {
  41. const schemas = new Set((await client.schemas()).map((item) => item.className));
  42. if (schemas.has(LEGACY_CLASS)) await client.deleteSchema(LEGACY_CLASS);
  43. const remainingSchemas = new Set((await client.schemas()).map((item) => item.className));
  44. if (remainingSchemas.has(LEGACY_CLASS)) throw new Error('legacy_listing_score_schema_still_exists');
  45. }
  46. const legacyRowsRemaining = schemaApiAvailable ? 0 : await retry(()=>client.count(LEGACY_CLASS, {}));
  47. if (legacyRowsRemaining !== 0) throw new Error(`legacy_listing_score_rows_remaining=${legacyRowsRemaining}`);
  48. console.log(JSON.stringify({ defaultWorkspaceId: workspaceId, legacyDeleted: legacy.length, currentScores: selected.size, jobItemReferencesCleaned: itemsToClean.length, versionReferencesCleaned: versionsToClean.length, legacyRowsRemaining, legacySchemaDeleted: schemaApiAvailable, schemaApiAvailable }, null, 2));
  49. }
  50. await main();