import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const FN_URL = process.env.SMOKE_FN_URL || 'https://server.fmode.cn/api/functions'; const APP_ID = process.env.SMOKE_PARSE_APP_ID || 'ncloudmaster'; const SESSION_TOKEN = process.env.SMOKE_SESSION_TOKEN || ''; const ACCOUNT_FILTER = String(process.env.IP_CLEANUP_ACCOUNT_ID || '').trim(); const INCLUDE_ALL_ACTIVE = /^(1|true|yes)$/i.test(String(process.env.IP_CLEANUP_INCLUDE_ALL_ACTIVE || '')); const PRINT_CANDIDATES = /^(1|true|yes)$/i.test(String(process.env.IP_CLEANUP_PRINT_CANDIDATES || '')); const OUTPUT_DIR = process.env.IP_CLEANUP_OUTPUT_DIR ? path.resolve(process.env.IP_CLEANUP_OUTPUT_DIR) : path.join(process.cwd(), 'tmp'); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..', '..'); const cloudFunctionsPath = path.join(rootDir, 'src', 'app', 'services', 'cloud-functions.ts'); const fn = readCloudFunctionIds(cloudFunctionsPath); const entityTypes = [ 'ipOperator.account', 'ipOperator.profile', 'ipOperator.plan', 'ipOperator.accountSnapshot', 'ipOperator.accountSnapshotWork', 'ipOperator.accountSnapshotComment', 'ipOperator.accountDiagnosis', 'ipOperator.positioningProposal', 'ipOperator.positioningVersion', 'ipOperator.contentDirection', 'ipOperator.operationTask', 'ipOperator.publishBinding', 'ipOperator.planEvidenceItem', 'ipOperator.planCommentPainInsight', 'ipOperator.planContentCalendarItem', 'ipOperator.planPublishPackage', 'ipOperator.planScriptBody', 'ipOperator.planGenerationPreview', ]; if (!SESSION_TOKEN) { console.error('Missing SMOKE_SESSION_TOKEN. This dry-run only reads the current Parse user namespace.'); process.exit(1); } if (!fn.systemStorage) { console.error('CLOUD_FN.systemStorage is empty. Check src/app/services/cloud-functions.ts.'); process.exit(1); } const startedAt = new Date().toISOString(); const rowsByType = new Map(); const allRows = []; for (const entityType of entityTypes) { const rows = await listRows(entityType); rowsByType.set(entityType, rows); allRows.push(...rows); } const stats = await call(fn.systemStorage, { action: 'stats' }).catch(() => null); const accounts = rowsByType.get('ipOperator.account') || []; const plans = rowsByType.get('ipOperator.plan') || []; const snapshots = rowsByType.get('ipOperator.accountSnapshot') || []; const works = rowsByType.get('ipOperator.accountSnapshotWork') || []; const comments = rowsByType.get('ipOperator.accountSnapshotComment') || []; const evidenceRows = rowsByType.get('ipOperator.planEvidenceItem') || []; const painRows = rowsByType.get('ipOperator.planCommentPainInsight') || []; const accountIds = new Set(); for (const account of accounts) accountIds.add(account.entityId); for (const row of [...snapshots, ...works, ...comments, ...evidenceRows, ...painRows]) { const accountId = row.data?.accountId || parseAccountIdFromSnapshotId(row.data?.snapshotId || row.entityId); if (accountId) accountIds.add(accountId); } const targetAccountIds = [...accountIds].filter((id) => !ACCOUNT_FILTER || id === ACCOUNT_FILTER); const candidates = []; for (const accountId of targetAccountIds) { collectAccountCandidates(accountId, candidates); } const typeStatus = summarizeByTypeAndStatus(allRows); const candidateSummary = summarizeByTypeAndStatus(candidates); const reportBase = `ip-operator-cleanup-dry-run-${safeTimestamp(startedAt)}`; const jsonPath = path.join(OUTPUT_DIR, `${reportBase}.json`); const mdPath = path.join(OUTPUT_DIR, `${reportBase}.md`); const report = buildReport({ generatedAt: startedAt, accountFilter: ACCOUNT_FILTER || '', stats: stats?.success ? stats.data : null, fetchedSummary: summaryToObject(typeStatus), candidateSummary: summaryToObject(candidateSummary), fetchedRowCount: allRows.length, candidateCount: candidates.length, candidates, }); fs.mkdirSync(OUTPUT_DIR, { recursive: true }); fs.writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); fs.writeFileSync(mdPath, renderMarkdownReport(report), 'utf8'); console.log(`IP operator cleanup dry-run generatedAt=${startedAt}`); console.log(`scope=current Parse user; accountFilter=${ACCOUNT_FILTER || '(all detected accounts)'}`); console.log(''); console.log('Current fetched rows by entityType/status:'); printSummary(typeStatus); if (stats?.success && stats.data?.totalEntities !== undefined) { console.log(''); console.log(`systemStorage.stats totalEntities=${stats.data.totalEntities}`); } console.log(''); console.log('Cleanup candidates by entityType/status:'); printSummary(candidateSummary); console.log(''); console.log(`Cleanup candidate rows: ${candidates.length}`); console.log(''); console.log(`Full JSON report: ${jsonPath}`); console.log(`Full Markdown report: ${mdPath}`); if (PRINT_CANDIDATES) { console.log(''); printCandidates(candidates); } console.log(''); console.log('DRY-RUN ONLY: no delete/purge/physical removal was executed.'); console.log('Next step: review candidate reasons. Only after confirmation should a separate execution script be used.'); async function listRows(entityType) { const statuses = INCLUDE_ALL_ACTIVE ? ['active', 'deleted', 'purged', 'archived'] : ['active', 'deleted', 'purged']; const merged = []; const seen = new Set(); for (const status of statuses) { const result = await call(fn.systemStorage, { action: 'list', entityType, status, limit: 1000 }); if (!result?.success) { console.warn(`WARN list failed entityType=${entityType} status=${status}: ${result?.error || JSON.stringify(result)}`); continue; } for (const row of result.data || []) { const key = row.objectId || `${row.entityType}:${row.entityId}:${row.status}`; if (seen.has(key)) continue; seen.add(key); merged.push(normalizeRow(row)); } } return merged; } function collectAccountCandidates(accountId, output) { const currentSnapshotId = `snapshot_${accountId}_current`; const currentPlanId = `ip_account_plan_${accountId}`; const plan = plans.find((row) => row.entityId === currentPlanId || row.data?.id === currentPlanId); const currentRefs = new Set([ ...toArray(plan?.data?.externalizedCollections?.evidenceItems), ...toArray(plan?.data?.evidenceItems).map((item) => item?.id).filter(Boolean), ]); const currentPainRefs = new Set([ ...toArray(plan?.data?.externalizedCollections?.commentPainInsights), ...toArray(plan?.data?.commentPainInsights).map((item) => item?.id).filter(Boolean), ]); const staleSnapshotIds = new Set(); for (const row of snapshots) { const rowAccountId = row.data?.accountId || parseAccountIdFromSnapshotId(row.entityId); if (rowAccountId !== accountId) continue; if (row.entityId === currentSnapshotId) continue; if (/^snapshot_.+_(current)$/.test(row.entityId)) continue; staleSnapshotIds.add(row.entityId); pushCandidate(output, row, accountId, `old account snapshot; keep ${currentSnapshotId}`); } for (const row of works) { const snapshotId = row.data?.snapshotId || parseSnapshotIdFromChildId(row.entityId); const rowAccountId = row.data?.accountId || parseAccountIdFromSnapshotId(snapshotId); if (rowAccountId !== accountId) continue; if (staleSnapshotIds.has(snapshotId)) { pushCandidate(output, row, accountId, `work belongs to old snapshot ${snapshotId}`); } } for (const row of comments) { const snapshotId = row.data?.snapshotId || parseSnapshotIdFromChildId(row.entityId); const rowAccountId = row.data?.accountId || parseAccountIdFromSnapshotId(snapshotId); if (rowAccountId !== accountId) continue; if (staleSnapshotIds.has(snapshotId)) { pushCandidate(output, row, accountId, `comment belongs to old snapshot ${snapshotId}`); } } for (const row of evidenceRows) { const data = row.data || {}; if (data.accountId !== accountId) continue; const evidenceId = data.id || row.entityId.split('__').pop(); const snapshotId = data.snapshotId || ''; if (staleSnapshotIds.has(snapshotId)) { pushCandidate(output, row, accountId, `evidence belongs to old snapshot ${snapshotId}`); continue; } if (plan && currentRefs.size && !currentRefs.has(evidenceId)) { pushCandidate(output, row, accountId, `evidence is not referenced by current plan ${currentPlanId}`); } } for (const row of painRows) { const data = row.data || {}; const planId = data.planId || row.entityId.split('__')[0]; if (planId !== currentPlanId) continue; const painId = data.id || row.entityId.split('__').pop(); const evidenceIds = toArray(data.evidenceItemIds); const referencesStaleEvidence = evidenceIds.some((id) => candidatesEvidenceIdsForAccount(output, accountId).has(id)); if (referencesStaleEvidence) { pushCandidate(output, row, accountId, 'pain insight references stale evidence'); continue; } if (plan && currentPainRefs.size && !currentPainRefs.has(painId)) { pushCandidate(output, row, accountId, `pain insight is not referenced by current plan ${currentPlanId}`); } } for (const row of allRows) { if ((row.status === 'deleted' || row.status === 'purged') && row.entityType.startsWith('ipOperator.')) { const rowAccountId = row.data?.accountId || parseAccountIdFromSnapshotId(row.data?.snapshotId || row.entityId); if (rowAccountId === accountId) pushCandidate(output, row, accountId, `already ${row.status}; physical cleanup candidate only after review`); } } } function candidatesEvidenceIdsForAccount(output, accountId) { return new Set(output .filter((row) => row.accountId === accountId && row.entityType === 'ipOperator.planEvidenceItem') .map((row) => row.data?.id || row.entityId.split('__').pop()) .filter(Boolean)); } function pushCandidate(output, row, accountId, reason) { const key = row.objectId || `${row.entityType}:${row.entityId}`; if (output.some((item) => (item.objectId || `${item.entityType}:${item.entityId}`) === key)) return; output.push({ ...row, accountId, reason }); } async function call(id, body) { let lastResult = null; let lastError = null; for (let attempt = 1; attempt <= 4; attempt += 1) { try { const response = await fetch(FN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': APP_ID, }, body: JSON.stringify({ id, _ApplicationId: APP_ID, sessionToken: SESSION_TOKEN, ...body }), }); const text = await response.text(); let result; try { result = JSON.parse(text); } catch { result = { code: response.status, success: false, error: text }; } lastResult = result; if (attempt < 4 && isRetryableResult(result)) { await sleep(600 * attempt); continue; } return result; } catch (error) { lastError = error; const message = `${error?.message || ''} ${error?.cause?.code || ''}`; if (attempt < 4 && isRetryableMessage(message)) { await sleep(600 * attempt); continue; } return { code: 500, success: false, error: error?.message || 'fetch failed' }; } } if (lastResult) return lastResult; return { code: 500, success: false, error: lastError?.message || 'fetch failed' }; } function isRetryableResult(result) { const message = `${result?.error || ''} ${result?.message || ''}`; return Number(result?.code || 0) >= 500 || isRetryableMessage(message); } function isRetryableMessage(message) { return /fetch failed|Failed to fetch|NetworkError|Load failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message || ''); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function readCloudFunctionIds(filePath) { const text = fs.readFileSync(filePath, 'utf8'); const ids = {}; const re = /(\w+):\s*'([^']*)'/g; let match; while ((match = re.exec(text))) ids[match[1]] = match[2]; return ids; } function normalizeRow(row) { return { objectId: row.objectId || '', entityType: row.entityType || row.type || '', entityId: row.entityId || row.id || '', status: row.status || 'active', createdAt: row.createdAt || '', updatedAt: row.updatedAt || '', data: row.data && typeof row.data === 'object' ? row.data : {}, }; } function summarizeByTypeAndStatus(rows) { const summary = new Map(); for (const row of rows) { const type = row.entityType || '(unknown)'; const status = row.status || '(unknown)'; if (!summary.has(type)) summary.set(type, {}); summary.get(type)[status] = (summary.get(type)[status] || 0) + 1; } return summary; } function printSummary(summary) { const types = [...summary.keys()].sort(); if (!types.length) { console.log(' (none)'); return; } for (const type of types) { const statuses = Object.entries(summary.get(type)) .sort(([a], [b]) => a.localeCompare(b)) .map(([status, count]) => `${status}:${count}`) .join(', '); console.log(` ${type} -> ${statuses}`); } } function printCandidates(rows) { if (!rows.length) { console.log(' (none)'); return; } const sorted = [...rows].sort((a, b) => a.accountId.localeCompare(b.accountId) || a.entityType.localeCompare(b.entityType) || a.entityId.localeCompare(b.entityId)); for (const row of sorted) { console.log([ ` account=${row.accountId}`, `type=${row.entityType}`, `status=${row.status}`, `entityId=${row.entityId}`, row.objectId ? `objectId=${row.objectId}` : '', `reason=${row.reason}`, ].filter(Boolean).join(' | ')); } } function buildReport(input) { return { generatedAt: input.generatedAt, scope: { parseUser: 'current sessionToken user', accountFilter: input.accountFilter, includeArchived: INCLUDE_ALL_ACTIVE, }, stats: input.stats, fetchedRowCount: input.fetchedRowCount, fetchedSummary: input.fetchedSummary, candidateCount: input.candidateCount, candidateSummary: input.candidateSummary, candidates: input.candidates.map((row) => ({ accountId: row.accountId, objectId: row.objectId, entityType: row.entityType, entityId: row.entityId, status: row.status, createdAt: row.createdAt, updatedAt: row.updatedAt, reason: row.reason, dataId: row.data?.id || '', snapshotId: row.data?.snapshotId || '', planId: row.data?.planId || '', workId: row.data?.workId || '', })), }; } function renderMarkdownReport(report) { const lines = [ '# IP Operator Cleanup Dry Run', '', `- generatedAt: ${report.generatedAt}`, `- scope: ${report.scope.parseUser}`, `- accountFilter: ${report.scope.accountFilter || '(all detected accounts)'}`, `- fetchedRowCount: ${report.fetchedRowCount}`, `- candidateCount: ${report.candidateCount}`, ]; if (report.stats?.totalEntities !== undefined) { lines.push(`- systemStorage.stats totalEntities: ${report.stats.totalEntities}`); } lines.push('', '## Current fetched rows by entityType/status', ''); lines.push(...summaryMarkdownLines(report.fetchedSummary)); lines.push('', '## Cleanup candidates by entityType/status', ''); lines.push(...summaryMarkdownLines(report.candidateSummary)); lines.push('', '## Candidate rows', ''); if (!report.candidates.length) { lines.push('(none)'); } else { lines.push('| accountId | entityType | status | entityId | objectId | reason |'); lines.push('|---|---|---|---|---|---|'); for (const row of report.candidates) { lines.push(`| ${escapeMd(row.accountId)} | ${escapeMd(row.entityType)} | ${escapeMd(row.status)} | ${escapeMd(row.entityId)} | ${escapeMd(row.objectId)} | ${escapeMd(row.reason)} |`); } } lines.push('', '> DRY-RUN ONLY: no delete/purge/physical removal was executed.', ''); return `${lines.join('\n')}\n`; } function summaryMarkdownLines(summary) { const types = Object.keys(summary).sort(); if (!types.length) return ['(none)']; return types.map((type) => { const statuses = Object.entries(summary[type]) .sort(([a], [b]) => a.localeCompare(b)) .map(([status, count]) => `${status}:${count}`) .join(', '); return `- ${type}: ${statuses}`; }); } function summaryToObject(summary) { const result = {}; for (const [type, statuses] of summary.entries()) { result[type] = { ...statuses }; } return result; } function safeTimestamp(value) { return String(value).replace(/[:.]/g, '-'); } function escapeMd(value) { return String(value ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, '
'); } function parseSnapshotIdFromChildId(value) { const text = String(value || ''); const index = text.indexOf('__'); return index >= 0 ? text.slice(0, index) : ''; } function parseAccountIdFromSnapshotId(value) { const text = String(value || ''); const match = text.match(/^snapshot_(.+?)_(?:current|\d{10,}.*)$/); return match?.[1] || ''; } function toArray(value) { return Array.isArray(value) ? value : []; }