import fs from 'node:fs'; import path from 'node:path'; const PARSE_HOST = normalizeParseHost(process.env.SMOKE_PARSE_HOST || 'https://server.fmode.cn'); const APP_ID = process.env.SMOKE_PARSE_APP_ID || 'ncloudmaster'; const SESSION_TOKEN = process.env.SMOKE_SESSION_TOKEN || ''; const CONFIRM_TEXT = 'DELETE_VIDEO_WORKFLOW_TEST_DATA'; const CONFIRM = readArg('--confirm') || process.env.VIDEO_WORKFLOW_CLEANUP_CONFIRM || ''; const EXECUTE = CONFIRM === CONFIRM_TEXT; const CONCURRENCY = clamp(Number(readArg('--concurrency') || process.env.VIDEO_WORKFLOW_CLEANUP_CONCURRENCY || 2), 1, 4); const OUTPUT_DIR = process.env.VIDEO_WORKFLOW_CLEANUP_OUTPUT_DIR ? path.resolve(process.env.VIDEO_WORKFLOW_CLEANUP_OUTPUT_DIR) : path.join(process.cwd(), 'tmp'); const PROJECT_KEY = 'video-workflow'; const ALLOWED_CLASSES = [ 'VideoWorkflowEntity', 'VideoWorkflowAudit', 'VideoWorkflowMigration', 'VideoWorkflowFileAsset', ]; const requestedClasses = parseRequestedClasses(readArg('--classes') || process.env.VIDEO_WORKFLOW_CLEANUP_CLASSES || ''); const targetClasses = requestedClasses.length ? requestedClasses : ALLOWED_CLASSES; if (!SESSION_TOKEN) { console.error('Missing SMOKE_SESSION_TOKEN. Refusing to inspect or delete Parse data.'); process.exit(1); } const invalidClasses = targetClasses.filter((className) => !ALLOWED_CLASSES.includes(className)); if (invalidClasses.length) { console.error(`Refusing to continue. Classes outside allowlist: ${invalidClasses.join(', ')}`); console.error(`Allowed classes: ${ALLOWED_CLASSES.join(', ')}`); process.exit(1); } const startedAt = new Date().toISOString(); const me = await parseRequest('GET', '/users/me'); const ownerId = me?.objectId || ''; if (!ownerId) { console.error('Could not resolve current Parse user from SMOKE_SESSION_TOKEN.'); process.exit(1); } const rowsByClass = {}; for (const className of targetClasses) { rowsByClass[className] = await listRows(className, ownerId); } const allTargets = Object.entries(rowsByClass).flatMap(([className, rows]) => rows.map((row) => ({ className, objectId: row.objectId, ownerId: row.ownerId || '', projectKey: row.projectKey || '', entityType: row.entityType || '', entityId: row.entityId || '', status: row.status || '', createdAt: row.createdAt || '', updatedAt: row.updatedAt || '', })), ); console.log(`VideoWorkflow Parse cleanup generatedAt=${startedAt}`); console.log(`mode=${EXECUTE ? 'EXECUTE_DELETE' : 'DRY_RUN_ONLY'}`); console.log(`ownerId=${ownerId}`); console.log(`classes=${targetClasses.join(', ')}`); console.log(''); console.log('Rows by class:'); for (const className of targetClasses) { console.log(`- ${className}: ${rowsByClass[className].length}`); } console.log(''); console.log(`Total target rows: ${allTargets.length}`); let deleteResults = []; if (!EXECUTE) { console.log(''); console.log('No data was deleted.'); console.log(`To execute physical delete, rerun with: --confirm ${CONFIRM_TEXT}`); } else { deleteResults = await deleteTargets(allTargets); } const completedAt = new Date().toISOString(); const failures = deleteResults.filter((item) => !item.ok); const report = { startedAt, completedAt, mode: EXECUTE ? 'execute' : 'dry-run', ownerId, allowedClasses: ALLOWED_CLASSES, targetClasses, totalTargets: allTargets.length, countsByClass: Object.fromEntries(targetClasses.map((className) => [className, rowsByClass[className].length])), deleteAttempted: deleteResults.length, deleteSuccess: deleteResults.filter((item) => item.ok).length, deleteFailures: failures.length, targets: allTargets, deleteResults, }; fs.mkdirSync(OUTPUT_DIR, { recursive: true }); const reportPath = path.join( OUTPUT_DIR, `videoworkflow-parse-cleanup-${EXECUTE ? 'delete' : 'dry-run'}-${safeTimestamp(completedAt)}.json`, ); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); console.log(''); console.log(`Cleanup ${EXECUTE ? 'execute' : 'dry-run'} complete.`); console.log(`Report: ${reportPath}`); if (failures.length) process.exit(1); async function listRows(className, ownerId) { const output = []; const pageSize = 1000; for (let skip = 0; ; skip += pageSize) { const query = new URLSearchParams(); query.set('where', JSON.stringify({ projectKey: PROJECT_KEY, ownerId })); query.set('limit', String(pageSize)); query.set('skip', String(skip)); query.set('order', 'createdAt'); query.set('keys', 'objectId,projectKey,ownerId,entityType,entityId,status,createdAt,updatedAt'); const data = await parseRequest('GET', `/classes/${encodeURIComponent(className)}?${query.toString()}`); const rows = Array.isArray(data.results) ? data.results : []; output.push(...rows); if (rows.length < pageSize) break; } return output; } async function deleteTargets(targets) { const results = []; let index = 0; await Promise.all(Array.from({ length: CONCURRENCY }, async () => { while (index < targets.length) { const currentIndex = index; index += 1; const target = targets[currentIndex]; results[currentIndex] = await deleteTarget(target); if ((currentIndex + 1) % 100 === 0 || currentIndex + 1 === targets.length) { console.log(`delete progress ${currentIndex + 1}/${targets.length}`); } } })); const failures = results.filter((item) => !item.ok); console.log(''); console.log(`Delete complete. success=${results.length - failures.length} failures=${failures.length}`); return results; } async function deleteTarget(target) { try { await parseRequest('DELETE', `/classes/${encodeURIComponent(target.className)}/${encodeURIComponent(target.objectId)}`); return { ...target, ok: true, error: '' }; } catch (error) { return { ...target, ok: false, error: error?.message || 'delete failed' }; } } async function parseRequest(method, parsePath) { const headers = { Accept: 'application/json', 'X-Parse-Application-Id': APP_ID, 'X-Parse-Session-Token': SESSION_TOKEN, }; let lastError = null; for (let attempt = 1; attempt <= 4; attempt += 1) { try { const response = await fetch(`${PARSE_HOST}/parse${parsePath}`, { method, headers }); const data = await response.json().catch(() => ({})); if (!response.ok || data.error) { const error = new Error(data.error || data.message || `Parse ${method} ${parsePath} failed`); error.status = response.status || 500; error.detail = data; if (attempt < 4 && isRetryableError(error)) { await sleep(500 * attempt); continue; } throw error; } return data; } catch (error) { lastError = error; if (attempt < 4 && isRetryableError(error)) { await sleep(500 * attempt); continue; } throw error; } } throw lastError || new Error(`Parse ${method} ${parsePath} failed`); } function parseRequestedClasses(value) { return String(value || '') .split(',') .map((item) => item.trim()) .filter(Boolean); } function readArg(name) { const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] || '' : ''; } function normalizeParseHost(value) { return String(value || 'https://server.fmode.cn').replace(/\/+$/, '').replace(/\/parse$/i, ''); } function isRetryableError(error) { const status = Number(error?.status || 0); const message = `${error?.message || ''} ${error?.cause?.code || ''} ${error?.detail?.error || ''}`; return status >= 500 || /fetch failed|Failed to fetch|NetworkError|Load failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message); } function clamp(value, min, max) { if (!Number.isFinite(value)) return min; return Math.max(min, Math.min(max, Math.floor(value))); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function safeTimestamp(value) { return String(value).replace(/[:.]/g, '-'); }