feat(control-plane): add libSQL Turso backend
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
#!/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;
|
||||
});
|
||||
Reference in New Issue
Block a user