ip-operator-cleanup-dry-run.mjs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import { fileURLToPath } from 'node:url';
  4. const FN_URL = process.env.SMOKE_FN_URL || 'https://server.fmode.cn/api/functions';
  5. const APP_ID = process.env.SMOKE_PARSE_APP_ID || 'ncloudmaster';
  6. const SESSION_TOKEN = process.env.SMOKE_SESSION_TOKEN || '';
  7. const ACCOUNT_FILTER = String(process.env.IP_CLEANUP_ACCOUNT_ID || '').trim();
  8. const INCLUDE_ALL_ACTIVE = /^(1|true|yes)$/i.test(String(process.env.IP_CLEANUP_INCLUDE_ALL_ACTIVE || ''));
  9. const PRINT_CANDIDATES = /^(1|true|yes)$/i.test(String(process.env.IP_CLEANUP_PRINT_CANDIDATES || ''));
  10. const OUTPUT_DIR = process.env.IP_CLEANUP_OUTPUT_DIR
  11. ? path.resolve(process.env.IP_CLEANUP_OUTPUT_DIR)
  12. : path.join(process.cwd(), 'tmp');
  13. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  14. const rootDir = path.resolve(__dirname, '..', '..');
  15. const cloudFunctionsPath = path.join(rootDir, 'src', 'app', 'services', 'cloud-functions.ts');
  16. const fn = readCloudFunctionIds(cloudFunctionsPath);
  17. const entityTypes = [
  18. 'ipOperator.account',
  19. 'ipOperator.profile',
  20. 'ipOperator.plan',
  21. 'ipOperator.accountSnapshot',
  22. 'ipOperator.accountSnapshotWork',
  23. 'ipOperator.accountSnapshotComment',
  24. 'ipOperator.accountDiagnosis',
  25. 'ipOperator.positioningProposal',
  26. 'ipOperator.positioningVersion',
  27. 'ipOperator.contentDirection',
  28. 'ipOperator.operationTask',
  29. 'ipOperator.publishBinding',
  30. 'ipOperator.planEvidenceItem',
  31. 'ipOperator.planCommentPainInsight',
  32. 'ipOperator.planContentCalendarItem',
  33. 'ipOperator.planPublishPackage',
  34. 'ipOperator.planScriptBody',
  35. 'ipOperator.planGenerationPreview',
  36. ];
  37. if (!SESSION_TOKEN) {
  38. console.error('Missing SMOKE_SESSION_TOKEN. This dry-run only reads the current Parse user namespace.');
  39. process.exit(1);
  40. }
  41. if (!fn.systemStorage) {
  42. console.error('CLOUD_FN.systemStorage is empty. Check src/app/services/cloud-functions.ts.');
  43. process.exit(1);
  44. }
  45. const startedAt = new Date().toISOString();
  46. const rowsByType = new Map();
  47. const allRows = [];
  48. for (const entityType of entityTypes) {
  49. const rows = await listRows(entityType);
  50. rowsByType.set(entityType, rows);
  51. allRows.push(...rows);
  52. }
  53. const stats = await call(fn.systemStorage, { action: 'stats' }).catch(() => null);
  54. const accounts = rowsByType.get('ipOperator.account') || [];
  55. const plans = rowsByType.get('ipOperator.plan') || [];
  56. const snapshots = rowsByType.get('ipOperator.accountSnapshot') || [];
  57. const works = rowsByType.get('ipOperator.accountSnapshotWork') || [];
  58. const comments = rowsByType.get('ipOperator.accountSnapshotComment') || [];
  59. const evidenceRows = rowsByType.get('ipOperator.planEvidenceItem') || [];
  60. const painRows = rowsByType.get('ipOperator.planCommentPainInsight') || [];
  61. const accountIds = new Set();
  62. for (const account of accounts) accountIds.add(account.entityId);
  63. for (const row of [...snapshots, ...works, ...comments, ...evidenceRows, ...painRows]) {
  64. const accountId = row.data?.accountId || parseAccountIdFromSnapshotId(row.data?.snapshotId || row.entityId);
  65. if (accountId) accountIds.add(accountId);
  66. }
  67. const targetAccountIds = [...accountIds].filter((id) => !ACCOUNT_FILTER || id === ACCOUNT_FILTER);
  68. const candidates = [];
  69. for (const accountId of targetAccountIds) {
  70. collectAccountCandidates(accountId, candidates);
  71. }
  72. const typeStatus = summarizeByTypeAndStatus(allRows);
  73. const candidateSummary = summarizeByTypeAndStatus(candidates);
  74. const reportBase = `ip-operator-cleanup-dry-run-${safeTimestamp(startedAt)}`;
  75. const jsonPath = path.join(OUTPUT_DIR, `${reportBase}.json`);
  76. const mdPath = path.join(OUTPUT_DIR, `${reportBase}.md`);
  77. const report = buildReport({
  78. generatedAt: startedAt,
  79. accountFilter: ACCOUNT_FILTER || '',
  80. stats: stats?.success ? stats.data : null,
  81. fetchedSummary: summaryToObject(typeStatus),
  82. candidateSummary: summaryToObject(candidateSummary),
  83. fetchedRowCount: allRows.length,
  84. candidateCount: candidates.length,
  85. candidates,
  86. });
  87. fs.mkdirSync(OUTPUT_DIR, { recursive: true });
  88. fs.writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
  89. fs.writeFileSync(mdPath, renderMarkdownReport(report), 'utf8');
  90. console.log(`IP operator cleanup dry-run generatedAt=${startedAt}`);
  91. console.log(`scope=current Parse user; accountFilter=${ACCOUNT_FILTER || '(all detected accounts)'}`);
  92. console.log('');
  93. console.log('Current fetched rows by entityType/status:');
  94. printSummary(typeStatus);
  95. if (stats?.success && stats.data?.totalEntities !== undefined) {
  96. console.log('');
  97. console.log(`systemStorage.stats totalEntities=${stats.data.totalEntities}`);
  98. }
  99. console.log('');
  100. console.log('Cleanup candidates by entityType/status:');
  101. printSummary(candidateSummary);
  102. console.log('');
  103. console.log(`Cleanup candidate rows: ${candidates.length}`);
  104. console.log('');
  105. console.log(`Full JSON report: ${jsonPath}`);
  106. console.log(`Full Markdown report: ${mdPath}`);
  107. if (PRINT_CANDIDATES) {
  108. console.log('');
  109. printCandidates(candidates);
  110. }
  111. console.log('');
  112. console.log('DRY-RUN ONLY: no delete/purge/physical removal was executed.');
  113. console.log('Next step: review candidate reasons. Only after confirmation should a separate execution script be used.');
  114. async function listRows(entityType) {
  115. const statuses = INCLUDE_ALL_ACTIVE
  116. ? ['active', 'deleted', 'purged', 'archived']
  117. : ['active', 'deleted', 'purged'];
  118. const merged = [];
  119. const seen = new Set();
  120. for (const status of statuses) {
  121. const result = await call(fn.systemStorage, { action: 'list', entityType, status, limit: 1000 });
  122. if (!result?.success) {
  123. console.warn(`WARN list failed entityType=${entityType} status=${status}: ${result?.error || JSON.stringify(result)}`);
  124. continue;
  125. }
  126. for (const row of result.data || []) {
  127. const key = row.objectId || `${row.entityType}:${row.entityId}:${row.status}`;
  128. if (seen.has(key)) continue;
  129. seen.add(key);
  130. merged.push(normalizeRow(row));
  131. }
  132. }
  133. return merged;
  134. }
  135. function collectAccountCandidates(accountId, output) {
  136. const currentSnapshotId = `snapshot_${accountId}_current`;
  137. const currentPlanId = `ip_account_plan_${accountId}`;
  138. const plan = plans.find((row) => row.entityId === currentPlanId || row.data?.id === currentPlanId);
  139. const currentRefs = new Set([
  140. ...toArray(plan?.data?.externalizedCollections?.evidenceItems),
  141. ...toArray(plan?.data?.evidenceItems).map((item) => item?.id).filter(Boolean),
  142. ]);
  143. const currentPainRefs = new Set([
  144. ...toArray(plan?.data?.externalizedCollections?.commentPainInsights),
  145. ...toArray(plan?.data?.commentPainInsights).map((item) => item?.id).filter(Boolean),
  146. ]);
  147. const staleSnapshotIds = new Set();
  148. for (const row of snapshots) {
  149. const rowAccountId = row.data?.accountId || parseAccountIdFromSnapshotId(row.entityId);
  150. if (rowAccountId !== accountId) continue;
  151. if (row.entityId === currentSnapshotId) continue;
  152. if (/^snapshot_.+_(current)$/.test(row.entityId)) continue;
  153. staleSnapshotIds.add(row.entityId);
  154. pushCandidate(output, row, accountId, `old account snapshot; keep ${currentSnapshotId}`);
  155. }
  156. for (const row of works) {
  157. const snapshotId = row.data?.snapshotId || parseSnapshotIdFromChildId(row.entityId);
  158. const rowAccountId = row.data?.accountId || parseAccountIdFromSnapshotId(snapshotId);
  159. if (rowAccountId !== accountId) continue;
  160. if (staleSnapshotIds.has(snapshotId)) {
  161. pushCandidate(output, row, accountId, `work belongs to old snapshot ${snapshotId}`);
  162. }
  163. }
  164. for (const row of comments) {
  165. const snapshotId = row.data?.snapshotId || parseSnapshotIdFromChildId(row.entityId);
  166. const rowAccountId = row.data?.accountId || parseAccountIdFromSnapshotId(snapshotId);
  167. if (rowAccountId !== accountId) continue;
  168. if (staleSnapshotIds.has(snapshotId)) {
  169. pushCandidate(output, row, accountId, `comment belongs to old snapshot ${snapshotId}`);
  170. }
  171. }
  172. for (const row of evidenceRows) {
  173. const data = row.data || {};
  174. if (data.accountId !== accountId) continue;
  175. const evidenceId = data.id || row.entityId.split('__').pop();
  176. const snapshotId = data.snapshotId || '';
  177. if (staleSnapshotIds.has(snapshotId)) {
  178. pushCandidate(output, row, accountId, `evidence belongs to old snapshot ${snapshotId}`);
  179. continue;
  180. }
  181. if (plan && currentRefs.size && !currentRefs.has(evidenceId)) {
  182. pushCandidate(output, row, accountId, `evidence is not referenced by current plan ${currentPlanId}`);
  183. }
  184. }
  185. for (const row of painRows) {
  186. const data = row.data || {};
  187. const planId = data.planId || row.entityId.split('__')[0];
  188. if (planId !== currentPlanId) continue;
  189. const painId = data.id || row.entityId.split('__').pop();
  190. const evidenceIds = toArray(data.evidenceItemIds);
  191. const referencesStaleEvidence = evidenceIds.some((id) => candidatesEvidenceIdsForAccount(output, accountId).has(id));
  192. if (referencesStaleEvidence) {
  193. pushCandidate(output, row, accountId, 'pain insight references stale evidence');
  194. continue;
  195. }
  196. if (plan && currentPainRefs.size && !currentPainRefs.has(painId)) {
  197. pushCandidate(output, row, accountId, `pain insight is not referenced by current plan ${currentPlanId}`);
  198. }
  199. }
  200. for (const row of allRows) {
  201. if ((row.status === 'deleted' || row.status === 'purged') && row.entityType.startsWith('ipOperator.')) {
  202. const rowAccountId = row.data?.accountId || parseAccountIdFromSnapshotId(row.data?.snapshotId || row.entityId);
  203. if (rowAccountId === accountId) pushCandidate(output, row, accountId, `already ${row.status}; physical cleanup candidate only after review`);
  204. }
  205. }
  206. }
  207. function candidatesEvidenceIdsForAccount(output, accountId) {
  208. return new Set(output
  209. .filter((row) => row.accountId === accountId && row.entityType === 'ipOperator.planEvidenceItem')
  210. .map((row) => row.data?.id || row.entityId.split('__').pop())
  211. .filter(Boolean));
  212. }
  213. function pushCandidate(output, row, accountId, reason) {
  214. const key = row.objectId || `${row.entityType}:${row.entityId}`;
  215. if (output.some((item) => (item.objectId || `${item.entityType}:${item.entityId}`) === key)) return;
  216. output.push({ ...row, accountId, reason });
  217. }
  218. async function call(id, body) {
  219. let lastResult = null;
  220. let lastError = null;
  221. for (let attempt = 1; attempt <= 4; attempt += 1) {
  222. try {
  223. const response = await fetch(FN_URL, {
  224. method: 'POST',
  225. headers: {
  226. 'Content-Type': 'application/json',
  227. 'X-Parse-Application-Id': APP_ID,
  228. },
  229. body: JSON.stringify({ id, _ApplicationId: APP_ID, sessionToken: SESSION_TOKEN, ...body }),
  230. });
  231. const text = await response.text();
  232. let result;
  233. try {
  234. result = JSON.parse(text);
  235. } catch {
  236. result = { code: response.status, success: false, error: text };
  237. }
  238. lastResult = result;
  239. if (attempt < 4 && isRetryableResult(result)) {
  240. await sleep(600 * attempt);
  241. continue;
  242. }
  243. return result;
  244. } catch (error) {
  245. lastError = error;
  246. const message = `${error?.message || ''} ${error?.cause?.code || ''}`;
  247. if (attempt < 4 && isRetryableMessage(message)) {
  248. await sleep(600 * attempt);
  249. continue;
  250. }
  251. return { code: 500, success: false, error: error?.message || 'fetch failed' };
  252. }
  253. }
  254. if (lastResult) return lastResult;
  255. return { code: 500, success: false, error: lastError?.message || 'fetch failed' };
  256. }
  257. function isRetryableResult(result) {
  258. const message = `${result?.error || ''} ${result?.message || ''}`;
  259. return Number(result?.code || 0) >= 500 || isRetryableMessage(message);
  260. }
  261. function isRetryableMessage(message) {
  262. return /fetch failed|Failed to fetch|NetworkError|Load failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message || '');
  263. }
  264. function sleep(ms) {
  265. return new Promise((resolve) => setTimeout(resolve, ms));
  266. }
  267. function readCloudFunctionIds(filePath) {
  268. const text = fs.readFileSync(filePath, 'utf8');
  269. const ids = {};
  270. const re = /(\w+):\s*'([^']*)'/g;
  271. let match;
  272. while ((match = re.exec(text))) ids[match[1]] = match[2];
  273. return ids;
  274. }
  275. function normalizeRow(row) {
  276. return {
  277. objectId: row.objectId || '',
  278. entityType: row.entityType || row.type || '',
  279. entityId: row.entityId || row.id || '',
  280. status: row.status || 'active',
  281. createdAt: row.createdAt || '',
  282. updatedAt: row.updatedAt || '',
  283. data: row.data && typeof row.data === 'object' ? row.data : {},
  284. };
  285. }
  286. function summarizeByTypeAndStatus(rows) {
  287. const summary = new Map();
  288. for (const row of rows) {
  289. const type = row.entityType || '(unknown)';
  290. const status = row.status || '(unknown)';
  291. if (!summary.has(type)) summary.set(type, {});
  292. summary.get(type)[status] = (summary.get(type)[status] || 0) + 1;
  293. }
  294. return summary;
  295. }
  296. function printSummary(summary) {
  297. const types = [...summary.keys()].sort();
  298. if (!types.length) {
  299. console.log(' (none)');
  300. return;
  301. }
  302. for (const type of types) {
  303. const statuses = Object.entries(summary.get(type))
  304. .sort(([a], [b]) => a.localeCompare(b))
  305. .map(([status, count]) => `${status}:${count}`)
  306. .join(', ');
  307. console.log(` ${type} -> ${statuses}`);
  308. }
  309. }
  310. function printCandidates(rows) {
  311. if (!rows.length) {
  312. console.log(' (none)');
  313. return;
  314. }
  315. const sorted = [...rows].sort((a, b) =>
  316. a.accountId.localeCompare(b.accountId)
  317. || a.entityType.localeCompare(b.entityType)
  318. || a.entityId.localeCompare(b.entityId));
  319. for (const row of sorted) {
  320. console.log([
  321. ` account=${row.accountId}`,
  322. `type=${row.entityType}`,
  323. `status=${row.status}`,
  324. `entityId=${row.entityId}`,
  325. row.objectId ? `objectId=${row.objectId}` : '',
  326. `reason=${row.reason}`,
  327. ].filter(Boolean).join(' | '));
  328. }
  329. }
  330. function buildReport(input) {
  331. return {
  332. generatedAt: input.generatedAt,
  333. scope: {
  334. parseUser: 'current sessionToken user',
  335. accountFilter: input.accountFilter,
  336. includeArchived: INCLUDE_ALL_ACTIVE,
  337. },
  338. stats: input.stats,
  339. fetchedRowCount: input.fetchedRowCount,
  340. fetchedSummary: input.fetchedSummary,
  341. candidateCount: input.candidateCount,
  342. candidateSummary: input.candidateSummary,
  343. candidates: input.candidates.map((row) => ({
  344. accountId: row.accountId,
  345. objectId: row.objectId,
  346. entityType: row.entityType,
  347. entityId: row.entityId,
  348. status: row.status,
  349. createdAt: row.createdAt,
  350. updatedAt: row.updatedAt,
  351. reason: row.reason,
  352. dataId: row.data?.id || '',
  353. snapshotId: row.data?.snapshotId || '',
  354. planId: row.data?.planId || '',
  355. workId: row.data?.workId || '',
  356. })),
  357. };
  358. }
  359. function renderMarkdownReport(report) {
  360. const lines = [
  361. '# IP Operator Cleanup Dry Run',
  362. '',
  363. `- generatedAt: ${report.generatedAt}`,
  364. `- scope: ${report.scope.parseUser}`,
  365. `- accountFilter: ${report.scope.accountFilter || '(all detected accounts)'}`,
  366. `- fetchedRowCount: ${report.fetchedRowCount}`,
  367. `- candidateCount: ${report.candidateCount}`,
  368. ];
  369. if (report.stats?.totalEntities !== undefined) {
  370. lines.push(`- systemStorage.stats totalEntities: ${report.stats.totalEntities}`);
  371. }
  372. lines.push('', '## Current fetched rows by entityType/status', '');
  373. lines.push(...summaryMarkdownLines(report.fetchedSummary));
  374. lines.push('', '## Cleanup candidates by entityType/status', '');
  375. lines.push(...summaryMarkdownLines(report.candidateSummary));
  376. lines.push('', '## Candidate rows', '');
  377. if (!report.candidates.length) {
  378. lines.push('(none)');
  379. } else {
  380. lines.push('| accountId | entityType | status | entityId | objectId | reason |');
  381. lines.push('|---|---|---|---|---|---|');
  382. for (const row of report.candidates) {
  383. lines.push(`| ${escapeMd(row.accountId)} | ${escapeMd(row.entityType)} | ${escapeMd(row.status)} | ${escapeMd(row.entityId)} | ${escapeMd(row.objectId)} | ${escapeMd(row.reason)} |`);
  384. }
  385. }
  386. lines.push('', '> DRY-RUN ONLY: no delete/purge/physical removal was executed.', '');
  387. return `${lines.join('\n')}\n`;
  388. }
  389. function summaryMarkdownLines(summary) {
  390. const types = Object.keys(summary).sort();
  391. if (!types.length) return ['(none)'];
  392. return types.map((type) => {
  393. const statuses = Object.entries(summary[type])
  394. .sort(([a], [b]) => a.localeCompare(b))
  395. .map(([status, count]) => `${status}:${count}`)
  396. .join(', ');
  397. return `- ${type}: ${statuses}`;
  398. });
  399. }
  400. function summaryToObject(summary) {
  401. const result = {};
  402. for (const [type, statuses] of summary.entries()) {
  403. result[type] = { ...statuses };
  404. }
  405. return result;
  406. }
  407. function safeTimestamp(value) {
  408. return String(value).replace(/[:.]/g, '-');
  409. }
  410. function escapeMd(value) {
  411. return String(value ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
  412. }
  413. function parseSnapshotIdFromChildId(value) {
  414. const text = String(value || '');
  415. const index = text.indexOf('__');
  416. return index >= 0 ? text.slice(0, index) : '';
  417. }
  418. function parseAccountIdFromSnapshotId(value) {
  419. const text = String(value || '');
  420. const match = text.match(/^snapshot_(.+?)_(?:current|\d{10,}.*)$/);
  421. return match?.[1] || '';
  422. }
  423. function toArray(value) {
  424. return Array.isArray(value) ? value : [];
  425. }