run-nightly-legacy-sync.mjs 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. #!/usr/bin/env node
  2. import { mkdir, writeFile, rename, rm } from 'node:fs/promises';
  3. import { homedir } from 'node:os';
  4. import { resolve, join } from 'node:path';
  5. import { randomUUID } from 'node:crypto';
  6. const defaultDatasets = ['nodes', 'vocabulary', 'special-training', 'course-bindings', 'learning-records', 'practice-records', 'memory-records', 'assessments', 'appointments', 'lessons'];
  7. const reportDir = resolve(process.env.XIAOSHU_SYNC_REPORT_DIR || join(homedir(), '.local/state/xiaoshu/legacy-sync'));
  8. const token = String(process.env.XIAOSHU_SYNC_OPERATOR_TOKEN || '').trim();
  9. const companyId = process.env.XIAOSHU_SYNC_COMPANY_ID || '7pIbDBJmKx';
  10. const functionUrl = (process.env.XIAOSHU_FUNCTION_URL || 'https://server.xiaoshu.pro/api/functions').replace(/\/$/, '');
  11. const bridgeUrl = (process.env.XIAOSHU_LEGACY_SYNC_BASE_URL || 'https://a018.2018.z01.com/xiaoshu-sync').replace(/\/$/, '');
  12. const appId = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
  13. const datasets = String(process.env.XIAOSHU_SYNC_DATASETS || defaultDatasets.join(',')).split(',').map(value => value.trim()).filter(Boolean);
  14. const maxRounds = Number(process.env.XIAOSHU_SYNC_MAX_ROUNDS || 200);
  15. const maxDurationMs = Number(process.env.XIAOSHU_SYNC_MAX_DURATION_MS || 2_700_000);
  16. const deadline = Date.now() + maxDurationMs;
  17. const runId = randomUUID();
  18. const businessDate = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date());
  19. const report = { runId, businessDate, timeZone: 'Asia/Shanghai', startedAt: new Date().toISOString(), status: 'running', reason: '', datasets: {}, retries: 0 };
  20. const lock = join(reportDir, 'nightly.lock');
  21. let locked = false;
  22. class RunError extends Error {
  23. constructor(reason, status = 'failed') { super(reason); this.runStatus = status; }
  24. }
  25. function abort(reason, status = 'blocked') { throw new RunError(reason, status); }
  26. async function request(url, options = {}, allowRetry = true) {
  27. for (let attempt = 0; ; attempt++) {
  28. if (Date.now() >= deadline) abort('run_time_limit', 'incomplete');
  29. try {
  30. const response = await fetch(url, { ...options, signal: AbortSignal.timeout(Math.min(90_000, deadline - Date.now())) });
  31. if (allowRetry && [429, 502, 503, 504].includes(response.status) && attempt < 2) {
  32. report.retries++;
  33. await new Promise(done => setTimeout(done, 1000 * (attempt + 1)));
  34. continue;
  35. }
  36. return response;
  37. } catch (error) {
  38. if (error instanceof RunError) throw error;
  39. if (!allowRetry || attempt >= 2) abort('network_request_failed', 'failed');
  40. report.retries++;
  41. await new Promise(done => setTimeout(done, 1000 * (attempt + 1)));
  42. }
  43. }
  44. }
  45. async function gateway(params) {
  46. const response = await request(`${functionUrl}/xiaoshu/ops/gateway-v3`, {
  47. method: 'POST',
  48. headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': appId },
  49. body: JSON.stringify({ token, params: { ...params, companyId } }),
  50. }, params.operation === 'ops/sync/status');
  51. if (!response.ok) abort(`gateway_http_${response.status}`);
  52. const payload = await response.json().catch(() => null);
  53. if (payload?.success !== true || !payload.data) abort('invalid_gateway_response');
  54. return payload.data;
  55. }
  56. function assertProtection(status) {
  57. // Do not infer protection from a caller-supplied flag. The deployed gateway
  58. // must advertise this only after its implementation and acceptance checks pass.
  59. if (status.syncProtection?.conflictPolicy !== 'preserve-new' || status.syncProtection?.atomicProjection !== true) {
  60. abort('preserve_new_conflict_protection_not_verified');
  61. }
  62. }
  63. try {
  64. await mkdir(reportDir, { recursive: true, mode: 0o700 });
  65. try { await mkdir(lock, { mode: 0o700 }); locked = true; }
  66. catch (error) { if (error.code === 'EEXIST') abort('another_run_or_stale_lock', 'skipped'); throw error; }
  67. await writeFile(join(lock, 'owner.json'), JSON.stringify({ runId, pid: process.pid, startedAt: report.startedAt }), { mode: 0o600 });
  68. if (!Number.isSafeInteger(maxRounds) || maxRounds < 1 || !Number.isSafeInteger(maxDurationMs) || maxDurationMs < 1) abort('invalid_run_limits');
  69. if (!datasets.length || new Set(datasets).size !== datasets.length || datasets.some(dataset => !defaultDatasets.includes(dataset))) abort('invalid_datasets');
  70. if (datasets.includes('memory-records') && !datasets.includes('learning-records')) abort('missing_learning_dependency');
  71. if (datasets.includes('lessons') && !datasets.includes('appointments')) abort('missing_appointment_dependency');
  72. const healthResponse = await request(`${bridgeUrl}/v1/health`);
  73. if (!healthResponse.ok) abort(`bridge_http_${healthResponse.status}`);
  74. const health = await healthResponse.json().catch(() => null);
  75. if (health?.status !== 'ok' || health.service !== 'xiaoshu-legacy-sync' || health.version !== 'v1') abort('wrong_bridge_service');
  76. if (!token) abort('missing_super_admin_token');
  77. if (process.env.XIAOSHU_SYNC_OPERATOR_EXPIRES_AT && !(Date.parse(process.env.XIAOSHU_SYNC_OPERATOR_EXPIRES_AT) > Date.now())) abort('expired_super_admin_token');
  78. const initial = await gateway({ operation: 'ops/sync/status' });
  79. if (!initial.configured || !initial.bridgeReachable) abort('bridge_not_configured_or_signed_manifest_unavailable');
  80. assertProtection(initial);
  81. if (process.argv.includes('--check')) {
  82. report.status = 'ready';
  83. } else {
  84. // Use a fixed topological order, regardless of environment list order.
  85. for (const dataset of defaultDatasets.filter(value => datasets.includes(value))) {
  86. const totals = report.datasets[dataset] = { rounds: 0, processed: 0, created: 0, updated: 0, deleted: 0, conflicts: 0, failures: 0, caughtUp: false };
  87. for (let round = 0; round < maxRounds; round++) {
  88. // A POST timeout is ambiguous. Stop and retain the server checkpoint;
  89. // the next scheduled run resumes it rather than replaying blindly.
  90. const result = await gateway({ operation: 'ops/sync/run', dataset, maxPages: 10, pageSize: 500, conflictPolicy: 'preserve-new' });
  91. if (result.dataset !== dataset || typeof result.hasMore !== 'boolean' || !Number.isFinite(result.failures)) abort('invalid_sync_result', 'failed');
  92. totals.rounds++;
  93. for (const key of ['processed', 'created', 'updated', 'deleted', 'conflicts', 'failures']) totals[key] += Number(result[key] || 0);
  94. if (result.failures) abort(`dataset_failed:${dataset}`, 'failed');
  95. if (!result.hasMore) { totals.caughtUp = true; break; }
  96. }
  97. if (!totals.caughtUp) abort(`round_limit:${dataset}`, 'incomplete');
  98. }
  99. const final = await gateway({ operation: 'ops/sync/status' });
  100. assertProtection(final);
  101. report.pendingFailures = Number(final.totalFailures || 0);
  102. report.pendingConflicts = Number(final.totalConflicts || 0);
  103. if (report.pendingFailures) abort('pending_failures', 'failed');
  104. if (report.pendingConflicts) abort('pending_conflicts', 'completed_with_conflicts');
  105. if (!final.configured || !final.bridgeReachable || !datasets.every(key => final.datasets?.some(row => row.key === key && row.reconciled === true))) {
  106. abort('business_id_checksum_reconciliation_not_verified', 'awaiting_reconciliation');
  107. }
  108. report.status = 'success';
  109. }
  110. } catch (error) {
  111. report.status = error.runStatus || 'failed';
  112. // Never persist a raw server error, token, payload, or business record.
  113. report.reason = error instanceof RunError ? error.message : 'unexpected_runner_error';
  114. } finally {
  115. report.finishedAt = new Date().toISOString();
  116. await mkdir(reportDir, { recursive: true, mode: 0o700 });
  117. const reportPath = join(reportDir, `${businessDate}-${runId}.json`);
  118. const temporary = `${reportPath}.tmp`;
  119. await writeFile(temporary, JSON.stringify(report, null, 2), { mode: 0o600 });
  120. await rename(temporary, reportPath);
  121. if (locked) await rm(lock, { recursive: true });
  122. console.log(JSON.stringify({ status: report.status, reason: report.reason, reportPath }));
  123. process.exitCode = ['success', 'ready'].includes(report.status) ? 0 : 1;
  124. }