#!/usr/bin/env node import { callSpacetimeProcedureViaCli, encodeSpacetimeCliOption, ensureProcedureOk, parsePositiveInteger, } from './spacetime-migration-common.mjs'; const MAX_BATCH_SIZE = 25; const DEFAULT_RETENTION_DAYS = 30; const MICROS_PER_DAY = 86_400_000_000; function usage() { return `用法: node scripts/spacetime-maintain-external-generation-jobs.mjs --database [选项] 默认只 dry-run 一批历史终态任务 payload 压缩,不修改数据库。 使用 --prune-history 时改为清理已确认通知且超过保留期的历史任务、摘要与事件。 公共选项: --database 目标数据库(必填,也可用 GENARRATIVE_SPACETIME_DATABASE) --server spacetime CLI server 名或 URL --server-url 显式 server URL --limit <1-${MAX_BATCH_SIZE}> 单批任务数,默认 10 --cursor-job-id 从上一批 next_cursor_job_id 继续 --apply 执行写入;省略时始终 dry-run --backfill-summaries 改为回填轻量摘要投影 --prune-history 改为清理已确认通知的终态历史 --owner-user-id 仅摘要回填可选,限定 owner --source-module 仅历史清理可选,默认 editor-canvas --retention-days 仅历史清理可选,默认 ${DEFAULT_RETENTION_DAYS} 天 --completed-before-micros 限定终态完成时间;历史清理默认按 retention-days 计算 --help 显示帮助 必须使用已授权 migration operator 的 spacetime CLI 登录态。脚本每次只处理一批; 根据返回的 next_cursor_job_id 与 has_more 手工继续,避免在生产一次长事务扫完整历史。`; } function parseOptions(argv) { const options = { apply: false, backfillSummaries: false, completedBeforeMicros: null, cursorJobId: '', database: process.env.GENARRATIVE_SPACETIME_DATABASE || '', limit: 10, ownerUserId: '', pruneHistory: false, retentionDays: DEFAULT_RETENTION_DAYS, sourceModule: 'editor-canvas', passthrough: [], server: process.env.GENARRATIVE_SPACETIME_SERVER || '', serverUrl: process.env.GENARRATIVE_SPACETIME_SERVER_URL || '', }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; const readValue = (name) => { const value = argv[index + 1]; if (!value || value.startsWith('--')) { throw new Error(`${name} 缺少参数值。`); } index += 1; return value; }; if (arg === '--database') { options.database = readValue(arg); } else if (arg === '--server') { options.server = readValue(arg); } else if (arg === '--server-url') { options.serverUrl = readValue(arg); } else if (arg === '--limit') { options.limit = parsePositiveInteger(readValue(arg), arg); } else if (arg === '--cursor-job-id') { options.cursorJobId = readValue(arg).trim(); } else if (arg === '--completed-before-micros') { const value = readValue(arg); if (!/^-?[0-9]+$/u.test(value)) { throw new Error(`${arg} 必须是整数。`); } const parsed = Number.parseInt(value, 10); if (!Number.isSafeInteger(parsed)) { throw new Error(`${arg} 超出 JavaScript 安全整数范围。`); } options.completedBeforeMicros = parsed; } else if (arg === '--owner-user-id') { options.ownerUserId = readValue(arg).trim(); } else if (arg === '--apply') { options.apply = true; } else if (arg === '--backfill-summaries') { options.backfillSummaries = true; } else if (arg === '--prune-history') { options.pruneHistory = true; } else if (arg === '--source-module') { options.sourceModule = readValue(arg).trim(); if (!options.sourceModule) { throw new Error('--source-module 不能为空。'); } } else if (arg === '--retention-days') { options.retentionDays = parsePositiveInteger(readValue(arg), arg); } else if (arg === '--help' || arg === '-h') { options.help = true; } else { throw new Error(`未知参数: ${arg}`); } } if (options.limit > MAX_BATCH_SIZE) { throw new Error(`--limit 不能超过 ${MAX_BATCH_SIZE}。`); } if (options.ownerUserId && !options.backfillSummaries) { throw new Error('--owner-user-id 只能与 --backfill-summaries 一起使用。'); } if (options.backfillSummaries && options.pruneHistory) { throw new Error('--backfill-summaries 与 --prune-history 不能同时使用。'); } if (options.sourceModule !== 'editor-canvas' && !options.pruneHistory) { throw new Error('--source-module 只能与 --prune-history 一起使用。'); } if ( options.retentionDays !== DEFAULT_RETENTION_DAYS && !options.pruneHistory ) { throw new Error('--retention-days 只能与 --prune-history 一起使用。'); } if (options.completedBeforeMicros !== null && options.backfillSummaries) { throw new Error('--completed-before-micros 不能用于摘要回填。'); } if ( options.completedBeforeMicros !== null && options.pruneHistory && options.retentionDays !== DEFAULT_RETENTION_DAYS ) { throw new Error( '--completed-before-micros 与 --retention-days 不能同时使用。', ); } return options; } function resolveRetentionCutoffMicros(options) { if (!options.pruneHistory) { return options.completedBeforeMicros; } if (options.completedBeforeMicros !== null) { return options.completedBeforeMicros; } const cutoff = Date.now() * 1000 - options.retentionDays * MICROS_PER_DAY; if (!Number.isSafeInteger(cutoff)) { throw new Error( '--retention-days 计算出的 completed_before_micros 超出安全整数范围。', ); } return cutoff; } try { const options = parseOptions(process.argv.slice(2)); if (options.help) { console.log(usage()); process.exit(0); } if (!options.database) { throw new Error( '必须传入 --database,或设置 GENARRATIVE_SPACETIME_DATABASE。', ); } const completedBeforeMicros = resolveRetentionCutoffMicros(options); const procedureName = options.pruneHistory ? 'prune_external_generation_job_history_and_return' : options.backfillSummaries ? 'backfill_external_generation_job_summaries_and_return' : 'compact_external_generation_job_payloads_and_return'; const input = options.pruneHistory ? { source_module: options.sourceModule, limit: options.limit, cursor_job_id: encodeSpacetimeCliOption(options.cursorJobId || null), completed_before_micros: completedBeforeMicros, dry_run: !options.apply, } : options.backfillSummaries ? { owner_user_id: encodeSpacetimeCliOption(options.ownerUserId || null), limit: options.limit, cursor_job_id: encodeSpacetimeCliOption(options.cursorJobId || null), dry_run: !options.apply, } : { dry_run: !options.apply, limit: options.limit, cursor_job_id: encodeSpacetimeCliOption(options.cursorJobId || null), completed_before_micros: encodeSpacetimeCliOption( completedBeforeMicros, ), }; const result = await callSpacetimeProcedureViaCli( options, procedureName, input, ); ensureProcedureOk(result); console.log( JSON.stringify( { procedure: procedureName, ...(options.pruneHistory ? { source_module: options.sourceModule, completed_before_micros: completedBeforeMicros, ...(options.completedBeforeMicros === null ? { retention_days: options.retentionDays } : {}), } : {}), ...result, }, null, 2, ), ); const pendingApplyCount = options.pruneHistory || options.backfillSummaries ? Number(result.selected_count ?? 0) : Number(result.matched_count ?? 0); if (result.has_more && options.apply) { console.log( `仍有后续批次;下一次追加 --cursor-job-id ${result.next_cursor_job_id ?? ''}。`, ); } else if (!options.apply && (result.has_more || pendingApplyCount > 0)) { const currentCursor = options.cursorJobId ? `保留 --cursor-job-id ${options.cursorJobId}` : '仍从首批开始'; const cutoffHint = options.pruneHistory ? `并固定 --completed-before-micros ${completedBeforeMicros}` : ''; console.log( `当前仅 dry-run;请${currentCursor}${cutoffHint}并追加 --apply 重跑同一批。apply 成功后再使用其 next_cursor_job_id 进入下一批。`, ); } } catch (error) { console.error( `[spacetime:external-generation:maintenance] ${error instanceof Error ? error.message : String(error)}`, ); process.exit(1); }