#!/usr/bin/env node "use strict"; /** * task-control-plane-admin-libsql-roundtrip-smoke.js * * 验证 control-plane-admin CLI 的 SQLite → libSQL local → SQLite 往返: * init → migrate-sqlite-to-target → export-target-to-sqlite * * 假设: * - 工作区根 /mnt/Data1T/mnote * - cargo 工作区包含 control-plane 包 * - libsql (turso) 原生依赖已可运行 (cargo build 通过) * * 不使用: * - 生产 control-plane 路径 * - sqlite3 CLI * 所有数据库文件创建在 /tmp */ const assert = require("node:assert"); const { spawnSync } = require("node:child_process"); const fs = require("node:fs/promises"); const path = require("node:path"); // ─── 配置 ────────────────────────────────────────────────────────── const PROJECT_ROOT = "/mnt/Data1T/mnote"; const CARGO_MANIFEST = path.join(PROJECT_ROOT, "rust/Cargo.toml"); const PKG = "control-plane"; const BIN = "control-plane-admin"; const TMP_PREFIX = path.join("/tmp", `cp-admin-smoke-${Date.now().toString(36)}`); // ─── 检查的键表 ──────────────────────────────────────────────────── const KEY_TABLES = ["users", "workspaces", "directory_grants", "auth_identities"]; // ─── 辅助 ────────────────────────────────────────────────────────── function cargoRun(...args) { const fullArgs = [ "run", "--manifest-path", CARGO_MANIFEST, "-p", PKG, "--bin", BIN, "--", ...args, ]; const result = spawnSync("cargo", fullArgs, { cwd: PROJECT_ROOT, encoding: "utf-8", maxBuffer: 16 * 1024 * 1024, env: { ...process.env, RUST_LOG: "error" }, }); const stdout = (result.stdout || "").trim(); const stderr = (result.stderr || "").trim(); if (result.error) { throw new Error(`cargo run 执行失败: ${result.error.message}\nARGS: ${fullArgs.join(" ")}`); } return { code: result.status, stdout, stderr }; } function parseJsonOutput(text, label) { try { return JSON.parse(text); } catch (e) { throw new Error(`${label}: 无法解析命令 JSON 输出\n原始输出:\n${text}`); } } function assertOk(json, label) { assert.strictEqual( json.ok, true, `${label}: json.ok 应为 true,实际为 ${JSON.stringify(json.ok)}`, ); } function assertTableRows(json, label) { const tables = json.tables; assert(tables !== undefined && tables !== null, `${label}: 应包含 tables 字段`); const missing = KEY_TABLES.filter((t) => !(t in tables)); assert.strictEqual( missing.length, 0, `${label}: tables 缺少键表: ${missing.join(", ")}`, ); for (const table of KEY_TABLES) { const count = tables[table]; assert( typeof count === "number" && count > 0, `${label}: ${table} 行数应为正值,实际为 ${count}`, ); } } async function cleanTempFiles() { try { const dir = path.dirname(TMP_PREFIX); const prefix = path.basename(TMP_PREFIX); const entries = await fs.readdir(dir); for (const entry of entries) { if (entry.startsWith(prefix)) { await fs.rm(path.join(dir, entry), { force: true, recursive: true }); } } } catch { /* cleanup 失败不阻塞最终报告 */ } } // ─── 主流程 ──────────────────────────────────────────────────────── async function main() { const sourceDb = `${TMP_PREFIX}-source.db`; const targetDb = `${TMP_PREFIX}-target.db`; const exportDb = `${TMP_PREFIX}-export.db`; let sourceOk = false; let migrateOk = false; let exportOk = false; console.log("=== control-plane-admin libSQL 往返 smoke ==="); console.log(` source: ${sourceDb}`); console.log(` target: ${targetDb}`); console.log(` export: ${exportDb}\n`); try { // ─── Step 1: init ───────────────────────────────────────────── console.log("── Step 1: init (SQLite) ──"); const r1 = cargoRun( "init", "--backend", "sqlite", "--sqlite-path", sourceDb, ); assert.strictEqual(r1.code, 0, `init exit code = ${r1.code}:\n${r1.stderr}`); const j1 = parseJsonOutput(r1.stdout, "init"); assertOk(j1, "init"); console.log(` ok, userId=${j1.userId}, workspaceId=${j1.workspaceId}\n`); sourceOk = true; // ─── Step 2: migrate-sqlite-to-target ───────────────────────── console.log("── Step 2: migrate-sqlite-to-target (→libSQL local) ──"); const r2 = cargoRun( "migrate-sqlite-to-target", "--source", sourceDb, "--backend", "libsql-local", "--libsql-path", targetDb, "--reset-target", ); assert.strictEqual(r2.code, 0, `migrate exit code = ${r2.code}:\n${r2.stderr}`); const j2 = parseJsonOutput(r2.stdout, "migrate-sqlite-to-target"); assertOk(j2, "migrate-sqlite-to-target"); assertTableRows(j2, "migrate-sqlite-to-target"); console.log(` ok, tables=${JSON.stringify(j2.tables)}\n`); migrateOk = true; // ─── Step 3: export-target-to-sqlite ────────────────────────── console.log("── Step 3: export-target-to-sqlite (libSQL→SQLite) ──"); const r3 = cargoRun( "export-target-to-sqlite", "--backend", "libsql-local", "--libsql-path", targetDb, "--output", exportDb, "--backup-existing", ); assert.strictEqual(r3.code, 0, `export exit code = ${r3.code}:\n${r3.stderr}`); const j3 = parseJsonOutput(r3.stdout, "export-target-to-sqlite"); assertOk(j3, "export-target-to-sqlite"); assertTableRows(j3, "export-target-to-sqlite"); console.log(` ok, tables=${JSON.stringify(j3.tables)}\n`); exportOk = true; // ─── 汇总 ───────────────────────────────────────────────────── console.log("=== 全部通过 ==="); console.log(` init: PASS (userId=${j1.userId})`); console.log(` migrate: PASS (tables=${Object.keys(j2.tables).length})`); console.log(` export: PASS (tables=${Object.keys(j3.tables).length})`); // 确认关键表在两次转储中行数一致 for (const table of KEY_TABLES) { const srcCount = j2.tables[table]; const expCount = j3.tables[table]; assert.strictEqual( srcCount, expCount, `${table} 行数不一致: migrate=${srcCount}, export=${expCount}`, ); } console.log(" 行数一致性检查: PASS"); } catch (err) { console.error("\n❌ FAILED:", err.message); process.exitCode = 1; } finally { await cleanTempFiles(); console.log("\n清理完成"); } } main().catch((err) => { console.error("unhandled error:", err); process.exitCode = 1; });