Files
mnote/scripts/migrate-control-plane-to-sqlite.js
lix-2026 47e224d419 chore: align sqlite control plane architecture
- replace default Convex control-plane wording with Rust SQLite control-plane across architecture, AGENTS, Reasonix, and design docs

- retire root Convex functions source and deploy script into recycle while keeping explicit cloud/compat/sync-replica boundaries

- add control-plane migration guard/docs and keep CodeGraph refreshed after the SQLite control-plane cutover
2026-05-22 17:45:22 +08:00

486 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
"use strict";
/**
* scripts/migrate-control-plane-to-sqlite.js
*
* Phase 0 dry-run 迁移工具 — 读取本地 control-plane 数据和工作区清单,
* 生成 migration-report.json 建议(不写 DB,不改现有文件)。
*
* 用法:
* node scripts/migrate-control-plane-to-sqlite.js --dry-run --out /tmp/report.json
* node scripts/migrate-control-plane-to-sqlite.js --dry-run --access-policy-path /custom/path/access-policy.json
* node scripts/migrate-control-plane-to-sqlite.js --dry-run --users-root /custom/users/root
* node scripts/migrate-control-plane-to-sqlite.js --dry-run --skip-users
*
* 参考设计稿:
* design/02-convex-rust-long-term-architecture/process/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md
*
* 如运行时文件系统扫描过慢,可使用 --from-snapshot 传入预收集的数据快照:
* 1. 用 Reasonix 工具扫描:list_directory + read_file 收集所有用户 workpace.json
* 2. 写入 JSON 文件(格式见 README
* 3. 传入工具: --from-snapshot /tmp/data-snapshot.json --skip-users
*/
const fs = require("node:fs");
const path = require("node:path");
// ─── 默认路径 ───────────────────────────────────────────────
const DEFAULT_DATA_ROOT = "/mnt/Data1T/Mnote_data";
const DEFAULT_ACCESS_POLICY = path.join(DEFAULT_DATA_ROOT, "control-plane", "access-policy.json");
const DEFAULT_USERS_ROOT = path.join(DEFAULT_DATA_ROOT, "users");
// ─── 版本标记 ───────────────────────────────────────────────
const TOOL_VERSION = "0.1.0";
const MIGRATION_PHASE = "Phase 0: dry-run & inventory";
// ─── CLI 参数解析 ────────────────────────────────────────────
function usage() {
return [
`migrate-control-plane-to-sqlite.js v${TOOL_VERSION}`,
"",
"用法:",
" node scripts/migrate-control-plane-to-sqlite.js --dry-run --out <路径>",
"",
"参数:",
" --dry-run 必要参数。启动干跑模式(仅扫描、不写 DB)。",
" --out <路径> 输出 migration-report.json 路径。",
" --access-policy-path <路径> 自定义 access-policy.json 路径。",
" --users-root <路径> 自定义用户根目录。",
" --from-snapshot <路径> 从预收集的数据快照文件读取,替代直接文件系统扫描。",
" --skip-users 跳过用户扫描(仅读取 access-policy)。",
" --verbose 输出详细扫描日志。",
" --help, -h 显示此帮助。",
"",
"示例:",
" node scripts/migrate-control-plane-to-sqlite.js --dry-run --out /tmp/mnote-control-plane-migration-report.json",
" node scripts/migrate-control-plane-to-sqlite.js --dry-run --out ./migration-report.json --verbose",
" node scripts/migrate-control-plane-to-sqlite.js --dry-run --from-snapshot ./data-snapshot.json --out /tmp/report.json",
].join("\n");
}
function parseArgs(argv) {
const opts = {
dryRun: false,
outPath: null,
accessPolicyPath: DEFAULT_ACCESS_POLICY,
usersRoot: DEFAULT_USERS_ROOT,
fromSnapshot: null,
skipUsers: false,
verbose: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--help" || arg === "-h") {
console.log(usage());
process.exit(0);
}
if (arg === "--dry-run") {
opts.dryRun = true;
continue;
}
if (arg === "--out") {
opts.outPath = argv[++i];
if (!opts.outPath) throw new Error("--out 需要参数");
continue;
}
if (arg === "--access-policy-path") {
opts.accessPolicyPath = argv[++i];
if (!opts.accessPolicyPath) throw new Error("--access-policy-path 需要参数");
continue;
}
if (arg === "--users-root") {
opts.usersRoot = argv[++i];
if (!opts.usersRoot) throw new Error("--users-root 需要参数");
continue;
}
if (arg === "--from-snapshot") {
opts.fromSnapshot = argv[++i];
if (!opts.fromSnapshot) throw new Error("--from-snapshot 需要参数");
continue;
}
if (arg === "--skip-users") {
opts.skipUsers = true;
continue;
}
if (arg === "--verbose") {
opts.verbose = true;
continue;
}
throw new Error(`未知参数:${arg}\n\n${usage()}`);
}
if (!opts.dryRun) {
throw new Error("必须指定 --dry-run(当前版本仅支持干跑模式)\n\n" + usage());
}
return opts;
}
// ─── 扫描工具函数 ──────────────────────────────────────────
function readJsonSafe(filePath, label) {
try {
if (!fs.existsSync(filePath)) {
return { ok: false, error: `${label} 文件不存在:${filePath}` };
}
const raw = fs.readFileSync(filePath, "utf8");
const data = JSON.parse(raw);
return { ok: true, data };
} catch (err) {
return { ok: false, error: `读取 ${label} 失败:${err.message}` };
}
}
function collectUsers(usersRoot) {
const results = [];
if (!fs.existsSync(usersRoot)) {
return { ok: false, error: `用户目录不存在:${usersRoot}`, users: [] };
}
const entries = fs.readdirSync(usersRoot, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".")) continue;
const userId = entry.name;
const userDir = path.join(usersRoot, userId);
const workspacesDir = path.join(userDir, "workspaces");
const userEntry = {
userId,
userDir,
workspacesDir,
workspaceCount: 0,
workspaces: [],
errors: [],
};
if (!fs.existsSync(workspacesDir)) {
userEntry.errors.push("无 workspaces 目录");
results.push(userEntry);
continue;
}
const wsEntries = fs.readdirSync(workspacesDir, { withFileTypes: true });
for (const ws of wsEntries) {
if (!ws.isDirectory()) continue;
if (ws.name.startsWith(".")) continue;
const wsDir = path.join(workspacesDir, ws.name);
const wsMetaDir = path.join(wsDir, ".mnote");
const wsMetaFile = path.join(wsMetaDir, "workspace.json");
const wsEntry = {
workspaceName: ws.name,
workspaceDir: wsDir,
metaFile: wsMetaFile,
meta: null,
pageCount: 0,
pages: [],
};
// 读取 workspace.json
const metaResult = readJsonSafe(wsMetaFile, `${userId}/${ws.name}/workspace.json`);
if (metaResult.ok) {
wsEntry.meta = metaResult.data;
} else {
userEntry.errors.push(`${ws.name}${metaResult.error}`);
}
// 统计页面(一级 .md 文件)
if (fs.existsSync(wsDir)) {
const pageFiles = fs.readdirSync(wsDir).filter((f) => f.endsWith(".md"));
wsEntry.pageCount = pageFiles.length;
wsEntry.pages = pageFiles;
}
userEntry.workspaces.push(wsEntry);
}
userEntry.workspaceCount = userEntry.workspaces.length;
results.push(userEntry);
}
return { ok: true, users: results };
}
// ─── 构建迁移报告 ──────────────────────────────────────────
function buildReport(opts, accessPolicyResult, usersResult) {
const report = {
meta: {
toolVersion: TOOL_VERSION,
migrationPhase: MIGRATION_PHASE,
generatedAt: new Date().toISOString(),
source: {
accessPolicy: opts.accessPolicyPath,
usersRoot: opts.usersRoot,
},
flags: {
dryRun: true,
skipUsers: opts.skipUsers,
},
},
accessPolicy: {
path: opts.accessPolicyPath,
readOk: accessPolicyResult.ok,
error: accessPolicyResult.ok ? null : accessPolicyResult.error,
admins: accessPolicyResult.ok ? (accessPolicyResult.data.admins || []) : [],
grants: accessPolicyResult.ok ? (accessPolicyResult.data.grants || []) : [],
},
users: {
scanOk: usersResult.ok,
error: usersResult.ok ? null : usersResult.error,
total: usersResult.ok ? usersResult.users.length : 0,
items: [],
},
suggestions: {
users: [],
workspaces: [],
grants: [],
legacyIdMap: [],
},
summary: {
adminCount: 0,
userCount: 0,
workspaceCount: 0,
grantCount: 0,
legacyMappingCount: 0,
errors: [],
},
};
if (!usersResult.ok) {
report.summary.errors.push(usersResult.error);
return report;
}
for (const u of usersResult.users) {
const userItem = {
userId: u.userId,
workspaceCount: u.workspaceCount,
errors: u.errors,
workspaces: [],
};
for (const ws of u.workspaces) {
const wsItem = {
workspaceName: ws.workspaceName,
meta: ws.meta,
pageCount: ws.pageCount,
};
userItem.workspaces.push(wsItem);
// ── 生成 workspace 建议 ──
if (ws.meta && ws.meta.workspaceId) {
const parts = ws.meta.workspaceId.split(":");
const newWsId = `sqlite-ws:${u.userId}:${ws.workspaceName}`;
report.suggestions.workspaces.push({
legacyWorkspaceId: ws.meta.workspaceId,
suggestedNewId: newWsId,
ownerId: u.userId,
name: ws.workspaceName,
rootPath: ws.workspaceDir,
source: "workspace.json",
});
// ── 生成 user 建议 ──
const existingUser = report.suggestions.users.find((x) => x.legacyUserId === u.userId);
if (!existingUser) {
// 检查是否在 access-policy 的 admins 中
const isAdmin = accessPolicyResult.ok && (accessPolicyResult.data.admins || []).includes(u.userId);
const newUserId = `usr_${u.userId.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
report.suggestions.users.push({
legacyUserId: u.userId,
suggestedNewId: newUserId,
role: isAdmin ? "admin" : "user",
isAdmin,
defaultWorkspace: ws.workspaceName,
source: "workspace.json",
});
// ── 生成 legacy_id_map 建议 ──
report.suggestions.legacyIdMap.push({
legacySystem: "local_workspace_manifest",
legacyKind: "user",
legacyId: u.userId,
newKind: "user",
newId: newUserId,
});
}
}
}
report.users.items.push(userItem);
}
// ── 生成 grant 建议 ──
if (accessPolicyResult.ok && accessPolicyResult.data.admins) {
for (const adminId of accessPolicyResult.data.admins) {
// 找到该 admin 对应的 user 建议
const adminUser = report.suggestions.users.find((x) => x.legacyUserId === adminId);
report.suggestions.grants.push({
legacyAdminId: adminId,
suggestedNewUserId: adminUser ? adminUser.suggestedNewId : adminId,
type: "admin",
rootUri: `mnote://users/${adminId}`,
rootPath: path.join(opts.usersRoot, adminId),
permission: "admin",
capabilities: ["admin", "share", "ai"],
source: "access-policy.json:admins",
});
}
}
if (accessPolicyResult.ok && accessPolicyResult.data.grants) {
for (const grant of accessPolicyResult.data.grants) {
report.suggestions.grants.push({
legacySource: "access-policy.json:grants",
source: grant,
type: "directory_grant",
...grant,
});
}
}
// ── 生成 legacy_id_map 建议(workspaces)──
for (const ws of report.suggestions.workspaces) {
report.suggestions.legacyIdMap.push({
legacySystem: "local_workspace_manifest",
legacyKind: "workspace",
legacyId: ws.legacyWorkspaceId,
newKind: "workspace",
newId: ws.suggestedNewId,
});
}
// ── 填写概要 ──
report.summary.adminCount = report.accessPolicy.admins.length;
report.summary.userCount = report.users.total;
report.summary.workspaceCount = report.suggestions.workspaces.length;
report.summary.grantCount = report.suggestions.grants.length;
report.summary.legacyMappingCount = report.suggestions.legacyIdMap.length;
// 收集所有错误
for (const u of usersResult.users) {
for (const err of u.errors) {
report.summary.errors.push(`[${u.userId}] ${err}`);
}
}
return report;
}
// ─── 入口 ──────────────────────────────────────────────────
function main() {
const opts = parseArgs(process.argv.slice(2));
if (opts.verbose) {
console.error(`[migrate-control-plane] 版本 ${TOOL_VERSION}`);
console.error(`[migrate-control-plane] access-policy: ${opts.accessPolicyPath}`);
console.error(`[migrate-control-plane] users-root: ${opts.usersRoot}`);
console.error(`[migrate-control-plane] skip-users: ${opts.skipUsers}`);
console.error(`[migrate-control-plane] dry-run: ${opts.dryRun}`);
}
// 1. 读取 access-policy.json(可能被快照覆盖,用 let)
let accessPolicyResult = readJsonSafe(opts.accessPolicyPath, "access-policy.json");
if (opts.verbose) {
if (accessPolicyResult.ok) {
console.error(`[migrate-control-plane] access-policy.json 读取成功`);
console.error(`[migrate-control-plane] admins: ${JSON.stringify(accessPolicyResult.data.admins)}`);
console.error(`[migrate-control-plane] grants: ${accessPolicyResult.data.grants ? accessPolicyResult.data.grants.length : 0} 条`);
} else {
console.error(`[migrate-control-plane] access-policy.json 读取失败: ${accessPolicyResult.error}`);
}
}
// 2. 扫描用户(支持从快照文件读取 vs 直接文件系统扫描)
let usersResult = { ok: true, users: [] };
if (opts.fromSnapshot) {
const snapshotResult = readJsonSafe(opts.fromSnapshot, "数据快照");
if (snapshotResult.ok) {
const snapshot = snapshotResult.data;
if (snapshot.users) {
usersResult = { ok: true, users: snapshot.users };
if (opts.verbose) {
console.error(`[migrate-control-plane] 从快照读取 ${snapshot.users.length} 个用户`);
}
// 如果快照中已有 accessPolicy,覆盖 access-policy.json 的读取
if (snapshot.accessPolicy && !opts.accessPolicyPath) {
const policyData = { ok: true, data: snapshot.accessPolicy };
// 替换 accessPolicyResult
accessPolicyResult = policyData;
if (opts.verbose) {
console.error(`[migrate-control-plane] 快照中的 accessPolicy 已生效`);
}
}
} else {
usersResult = { ok: false, error: "快照文件中缺少 users 字段", users: [] };
}
} else {
usersResult = { ok: false, error: snapshotResult.error, users: [] };
}
} else if (!opts.skipUsers) {
usersResult = collectUsers(opts.usersRoot);
if (opts.verbose) {
if (usersResult.ok) {
console.error(`[migrate-control-plane] 用户扫描完成: ${usersResult.users.length} 个用户`);
for (const u of usersResult.users) {
console.error(`[migrate-control-plane] ${u.userId}: ${u.workspaceCount} 个工作区`);
}
} else {
console.error(`[migrate-control-plane] 用户扫描失败: ${usersResult.error}`);
}
}
} else {
if (opts.verbose) {
console.error(`[migrate-control-plane] 用户扫描已跳过 (--skip-users)`);
}
}
// 3. 构建报告
const report = buildReport(opts, accessPolicyResult, usersResult);
// 4. 输出
const json = JSON.stringify(report, null, 2);
if (opts.outPath) {
fs.mkdirSync(path.dirname(opts.outPath), { recursive: true });
fs.writeFileSync(opts.outPath, json, "utf8");
if (opts.verbose) {
console.error(`[migrate-control-plane] 报告已写入: ${opts.outPath}`);
}
}
// stdout 只输出概要(便于管道处理)
const brief = {
ok: true,
tool: "migrate-control-plane-to-sqlite",
dryRun: true,
summary: report.summary,
outPath: opts.outPath || "(stdout)",
};
console.log(JSON.stringify(brief, null, 2));
// 如果有错误但不太严重,仍然 exit 0
// 严重错误(access-policy 读不到、用户目录不存在)通过 summary.errors 和 accessPolicy.error 体现
const fatalCount = report.summary.errors.length;
if (fatalCount > 0) {
console.error(`[migrate-control-plane] 警告:扫描中发现 ${fatalCount} 个非致命错误,详见报告 errors`);
}
}
if (require.main === module) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}