luckytri 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/docs/en/guide.md +10 -0
  2. package/docs/zh/guide.md +10 -0
  3. package/package.json +3 -2
  4. package/public/app/assets/{ChatsPage-B9Odvzfz.js → ChatsPage-BhlJHseG.js} +1 -1
  5. package/public/app/assets/{Empty-SkA8PyMo.js → Empty-DP6WMGHi.js} +1 -1
  6. package/public/app/assets/{Field-BjLhi8pL.js → Field-CtbdqaiB.js} +1 -1
  7. package/public/app/assets/{HeartPage-CscKqMsr.js → HeartPage-32BeTPn2.js} +1 -1
  8. package/public/app/assets/{LifePage-BHCmBHlH.js → LifePage-CxSm0XKI.js} +1 -1
  9. package/public/app/assets/{MemoryPage-Bhs2xa1k.js → MemoryPage-BeKBcPCY.js} +1 -1
  10. package/public/app/assets/{NaturePage-CaPIar_5.js → NaturePage-NEPYl2HS.js} +1 -1
  11. package/public/app/assets/{PeoplePage-CuwSSVeX.js → PeoplePage-DxtpekwM.js} +1 -1
  12. package/public/app/assets/{PluginsPage-BhVm1SUi.js → PluginsPage-DksEZbUU.js} +1 -1
  13. package/public/app/assets/{SystemPage-5yYqyUgR.css → SystemPage-BkRu0Rvx.css} +1 -1
  14. package/public/app/assets/SystemPage-BvzJlHuY.js +1 -0
  15. package/public/app/assets/{TimePage-BUm4eUzB.js → TimePage-7-awH5XL.js} +1 -1
  16. package/public/app/assets/{index-BIAdfObg.js → index-4rItIkD-.js} +3 -3
  17. package/public/app/assets/{knowledge-DNDLyN9m.js → knowledge-C6_HvoyB.js} +1 -1
  18. package/public/app/assets/{media-CKVAvML2.js → media-SMfTT-yc.js} +1 -1
  19. package/public/app/index.html +2 -2
  20. package/public/guide.en.html +43 -3
  21. package/public/guide.html +26 -3
  22. package/public/tutorial.en.md +10 -0
  23. package/public/tutorial.md +10 -0
  24. package/scripts/import-data.js +128 -0
  25. package/server/app.js +3 -1
  26. package/server/i18n/errors.js +36 -0
  27. package/server/index.js +46 -2
  28. package/server/knowledge/schema.js +12 -8
  29. package/server/storage/archive.js +5 -4
  30. package/server/storage/backup-cleanup.js +1 -0
  31. package/server/storage/data-package.js +298 -0
  32. package/server/storage/data-transfer-api.js +50 -0
  33. package/server/storage/data-transfer-worker.js +18 -0
  34. package/server/storage/data-transfer.js +415 -0
  35. package/server/storage/store.js +5 -2
  36. package/public/app/assets/SystemPage-Chm5fiGP.js +0 -1
@@ -168,6 +168,16 @@ Cleanup verifies a retained automatic backup of this instance from the last 72 h
168
168
 
169
169
  To restore, stop the service, move the current `data/friend.db` and its `-wal` and `-shm` files away, then copy the chosen backup to `data/friend.db`. When `DB_PATH` is set, work on that path. Never keep an old WAL file next to a restored database. The safer way is `npm run recovery`, which verifies a backup and restores it into a brand new file; see `docs/en/recovery.md`.
170
170
 
171
+ ### Import and export from the studio
172
+
173
+ "System → Data and backups" provides "Export data package" and "Choose import file". A `.luckytri` package contains chats, memories, relationships, personal state, diaries, tasks, work versions, knowledge text and settings stored in the database, including saved credentials. Management passwords and login sessions are not migrated. Plugin programs, original knowledge files and the local `.env` are outside the package. Export uses a consistent snapshot while the instance continues running and streams large downloads.
174
+
175
+ Imports accept `.luckytri` packages and existing `.db` / SQLite backups. The default upload limit is 10 GiB; adjust `DATA_TRANSFER_MAX_GB` (1–100) in `.env`. Uploads are checked for package SHA-256, database integrity, references and supported structures. Older versions are upgraded only in staging. The preview shows the name, version and record counts before current data changes, so it can be cancelled.
176
+
177
+ Confirming "Import and restart" stops the instance, retains a complete `luckytri-before-import-*.db` snapshot, replaces the data and restarts automatically. The local management password, current logins, QQ connection settings and backup cleanup rules are preserved. Automatic replies are turned off and plugin execution grants reset. Review connections, models and tasks before enabling replies; reinstall plugins or review their permissions. Import replaces the whole database rather than merging records. Pre-import snapshots can be managed manually and are excluded from scheduled full-backup cleanup.
178
+
179
+ The server removes the export copy after download. Cancelled, expired and interrupted staging files are reclaimed; unused previews expire after one hour. Failed imports retain or restore the original database rather than starting with a partial file.
180
+
171
181
  ## 9. Troubleshooting
172
182
 
173
183
  | Symptom | Check first |
@@ -168,6 +168,16 @@ TA 的一切都存在这一个数据库里,所以服务运行时会自动备
168
168
 
169
169
  恢复时先停止服务,把当前 `data/friend.db` 及同名 `-wal`、`-shm` 移开,再把选中的备份复制为 `data/friend.db`。设置了 `DB_PATH` 时操作该路径。不要把旧的 WAL 文件和恢复后的数据库放在一起。更稳妥的做法是用 `npm run recovery` 校验并恢复到一个全新的文件,见 `docs/zh/recovery.md`。
170
170
 
171
+ ### 从管理台导入与导出
172
+
173
+ 「系统 → 数据与备份」提供「导出数据包」和「选择导入文件」。导出的 `.luckytri` 文件包含数据库内的聊天、记忆、关系、个人状态、日记、待办、作品版本、知识文本和设置,也包含保存在设置中的密钥。管理密码和登录会话不随数据包迁移;插件程序、知识库原始文件及本机 `.env` 不在其中。导出读取一致快照,实例可以继续运行,大文件通过流式传输下载。
174
+
175
+ 导入支持 `.luckytri` 数据包及已有 `.db` / SQLite 备份。默认单个文件最多 10 GiB,可用 `.env` 中的 `DATA_TRANSFER_MAX_GB`(1–100)调整。上传后先校验数据包 SHA-256、数据库完整性、引用和结构,旧版本只在暂存副本上升级,界面会显示姓名、版本和记录数量。此时尚未改变当前数据,可以取消。
176
+
177
+ 确认「导入并重启」后,程序停止实例,保留一份完整的 `luckytri-before-import-*.db` 快照,再替换数据并自动重启。本机管理密码、当前登录、QQ 连接配置和备份整理规则保留。导入后的自动回复关闭,插件执行权限重置;检查连接、模型和待办后可重新开启,插件需重新安装或确认权限。导入是替换整份数据,不与当前记录合并;原有快照可在备份列表手动管理,定时清理完整备份不会删除它。
178
+
179
+ 下载完成后删除服务器导出副本;取消、失效或中断的暂存文件会回收,未使用的预览默认一小时失效。导入失败时保留或恢复原数据,不会把不完整的文件交给实例运行。
180
+
171
181
  ## 9. 常见问题
172
182
 
173
183
  | 现象 | 先检查 |
@@ -0,0 +1,128 @@
1
+ import { spawn } from "node:child_process";
2
+ import { openSync, closeSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { prepareRuntime } from "./runtime.js";
5
+ import { claimLaunch, clearStarting } from "../server/startup-lock.js";
6
+ import { applyDataImport } from "../server/storage/data-package.js";
7
+ import {
8
+ transferRoot,
9
+ readTransferJob,
10
+ writeTransferJob,
11
+ IMPORT_FAILURE,
12
+ IMPORT_START_FAILURE,
13
+ IMPORT_RESTART_FAILURE,
14
+ } from "../server/storage/data-transfer.js";
15
+
16
+ const id = process.argv[2];
17
+ const parent = Number(process.argv[3]);
18
+ let job,
19
+ paths,
20
+ claimed = false;
21
+ const alive = (pid) => {
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ } catch (error) {
26
+ return error.code !== "ESRCH";
27
+ }
28
+ };
29
+ try {
30
+ paths = prepareRuntime({ initialize: false });
31
+ if (!Number.isInteger(parent) || parent <= 0) throw Error("导入启动信息无效");
32
+ job = readTransferJob(transferRoot(), id);
33
+ if (job.kind !== "import" || job.status !== "queued")
34
+ throw Error("导入任务已经处理或不可用");
35
+ for (let attempt = 0; attempt < 160 && alive(parent); attempt++)
36
+ await new Promise((resolve) => setTimeout(resolve, 250));
37
+ if (alive(parent)) throw Error("旧实例仍在运行,未替换数据");
38
+ if (!claimLaunch()) throw Error("另一次启动正在进行,未替换数据");
39
+ claimed = true;
40
+ try {
41
+ const result = applyDataImport(
42
+ join(transferRoot(), id, "data.db"),
43
+ paths.database,
44
+ paths.backups,
45
+ {
46
+ id,
47
+ beforeSwap(backup) {
48
+ job = { ...job, backup };
49
+ writeTransferJob(transferRoot(), job);
50
+ },
51
+ },
52
+ );
53
+ job = {
54
+ ...job,
55
+ status: "done",
56
+ applied: Date.now(),
57
+ ...result,
58
+ };
59
+ try {
60
+ writeTransferJob(transferRoot(), job);
61
+ } catch {
62
+ console.error("导入结果记录未写入,重启后会核对已提交的数据。");
63
+ }
64
+ console.log("数据导入完成,当前数据快照已保留。");
65
+ } catch (error) {
66
+ writeTransferJob(transferRoot(), {
67
+ ...job,
68
+ status: "error",
69
+ error: IMPORT_FAILURE,
70
+ });
71
+ console.error("数据导入未完成:", error.code || "校验或替换失败");
72
+ }
73
+ // Hand the launch claim directly to the new server so no other launch can
74
+ // open the database halfway through the swap.
75
+ const log = openSync(paths.log, "a", 0o600);
76
+ const child = spawn(
77
+ process.execPath,
78
+ [
79
+ ...(process.execArgv.includes("--use-env-proxy")
80
+ ? ["--use-env-proxy"]
81
+ : []),
82
+ join(paths.package, "server", "index.js"),
83
+ ],
84
+ {
85
+ cwd: paths.home,
86
+ env: { ...process.env, LUCKYTRI_LAUNCH_PID: String(process.pid) },
87
+ detached: true,
88
+ windowsHide: true,
89
+ stdio: ["ignore", log, log],
90
+ },
91
+ );
92
+ closeSync(log);
93
+ await new Promise((resolve, reject) => {
94
+ child.once("spawn", resolve);
95
+ child.once("error", reject);
96
+ });
97
+ child.unref();
98
+ for (let attempt = 0; attempt < 120; attempt++) {
99
+ try {
100
+ const info = JSON.parse(
101
+ (await import("node:fs")).readFileSync(
102
+ join(paths.data, "startup.json"),
103
+ "utf8",
104
+ ),
105
+ );
106
+ if (info.pid === child.pid && info.role === "server") {
107
+ claimed = false;
108
+ break;
109
+ }
110
+ } catch {}
111
+ await new Promise((resolve) => setTimeout(resolve, 250));
112
+ }
113
+ } catch (error) {
114
+ if (job) {
115
+ try {
116
+ writeTransferJob(transferRoot(), {
117
+ ...job,
118
+ status: "error",
119
+ error:
120
+ job.status === "done" ? IMPORT_RESTART_FAILURE : IMPORT_START_FAILURE,
121
+ });
122
+ } catch {}
123
+ }
124
+ console.error("导入启动失败:", error.code || "请查看数据任务状态");
125
+ process.exitCode = 1;
126
+ } finally {
127
+ if (claimed) clearStarting();
128
+ }
package/server/app.js CHANGED
@@ -12,6 +12,7 @@ import { mountMind } from "./mind/api.js";
12
12
  import { mountPlugins } from "./plugins/api.js";
13
13
  import { Life } from "./mind/life/index.js";
14
14
  import { runtimePaths } from "./paths.js";
15
+ import { mountDataTransfer } from "./storage/data-transfer-api.js";
15
16
 
16
17
  export function createApp({
17
18
  store,
@@ -39,7 +40,7 @@ export function createApp({
39
40
  if (
40
41
  ["POST", "PATCH", "PUT"].includes(req.method) &&
41
42
  (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) &&
42
- req.path !== "/plugins/import/upload"
43
+ !["/plugins/import/upload", "/storage/transfer/upload"].includes(req.path)
43
44
  )
44
45
  return res.status(400).json({ error: "请提交 JSON 对象" });
45
46
  next();
@@ -56,6 +57,7 @@ export function createApp({
56
57
  res.status(401).json({ error: "请先登录管理台" });
57
58
  });
58
59
  app.use("/api", objectBody);
60
+ mountDataTransfer(app, runtime.transfer);
59
61
  if (plugins) {
60
62
  app.post(
61
63
  "/api/plugins/import/upload",
@@ -2,6 +2,42 @@
2
2
  // key, exactly as the code writes it. `{0}` stands for a value the code puts
3
3
  // into the message; patterns are used when a message carries one.
4
4
  export const ERRORS = {
5
+ 数据清单过大: "The data manifest is too large",
6
+ 数据文件包含不支持的数据库结构:
7
+ "The file contains unsupported database structures",
8
+ "不是有效的 LuckyTri 数据文件": "This is not a valid LuckyTri data file",
9
+ 数据包已损坏: "The data package is damaged",
10
+ 当前实例不支持文件导入: "This instance does not support file imports",
11
+ "当前数据库仍在使用,请稍后导入":
12
+ "The database is still in use. Import it later.",
13
+ 请上传数据包或数据库文件: "Upload a data package or database file",
14
+ 数据任务不存在: "The data operation does not exist",
15
+ 数据处理失败: "Data processing failed",
16
+ "数据已导入,但自动重启未完成。请手动重新启动实例。":
17
+ "Data was imported, but the automatic restart did not complete. Restart the instance manually.",
18
+ "临时数据文件正在使用,请稍后再试":
19
+ "Temporary data files are in use. Try again later.",
20
+ "数据处理被中断,请重新操作":
21
+ "Data processing was interrupted. Start the operation again.",
22
+ "数据处理或备份正在进行,请稍后再试":
23
+ "Data processing or backup is in progress. Try again later.",
24
+ 当前实例不支持文件导入导出:
25
+ "This instance does not support file import and export",
26
+ 数据文件超过导入大小限制: "The data file exceeds the import size limit",
27
+ 数据文件为空: "The data file is empty",
28
+ 数据上传失败: "Data upload failed",
29
+ 导出文件尚未就绪或已过期: "The export is not ready or has expired",
30
+ "当前实例不支持自动导入,请使用恢复命令":
31
+ "Automatic import is unavailable. Use the recovery command.",
32
+ 导入数据尚未就绪或已过期: "The import is not ready or has expired",
33
+ "数据处理正在进行,请稍后再试":
34
+ "Data processing is in progress. Try again later.",
35
+ 磁盘空间不足: "There is not enough disk space",
36
+ 数据文件无法校验: "The data file could not be verified",
37
+ "导入未完成,原数据已保留。请重新上传或查看运行日志。":
38
+ "Import could not complete. The original data was retained. Upload again or check the runtime log.",
39
+ "导入启动未完成,未替换运行数据。请重新启动实例。":
40
+ "Import could not start. The running data was not replaced. Restart the instance.",
5
41
  请使用本机命令重置管理密码:
6
42
  "Use the local CLI to reset the management password",
7
43
  "LUCKYTRI_HOME / --home 必须是绝对路径 (must be an absolute path)":
package/server/index.js CHANGED
@@ -8,6 +8,11 @@ import { createBackupScheduler } from "./storage/backup-scheduler.js";
8
8
  import { createBackupCleanup } from "./storage/backup-cleanup.js";
9
9
  import { PluginHost } from "./plugins/host.js";
10
10
  import { createManagementAuth } from "./auth.js";
11
+ import { createDataTransfer } from "./storage/data-transfer.js";
12
+ import { runtimePaths } from "./paths.js";
13
+ import { spawn } from "node:child_process";
14
+ import { openSync, closeSync } from "node:fs";
15
+ import { join } from "node:path";
11
16
 
12
17
  claimServer();
13
18
  process.on("exit", clearStarting);
@@ -37,6 +42,43 @@ plugins
37
42
  .catch((error) => console.error(`插件没有启动:${error.message}`));
38
43
  const backups = createBackupScheduler();
39
44
  const cleanup = createBackupCleanup({ store, backup: backups });
45
+ const transfer = createDataTransfer({
46
+ backup: backups,
47
+ cleanup,
48
+ async onApply(id) {
49
+ const paths = runtimePaths();
50
+ const log = openSync(paths.log, "a", 0o600);
51
+ try {
52
+ const child = spawn(
53
+ process.execPath,
54
+ [
55
+ ...(process.execArgv.includes("--use-env-proxy")
56
+ ? ["--use-env-proxy"]
57
+ : []),
58
+ join(paths.package, "scripts", "import-data.js"),
59
+ id,
60
+ String(process.pid),
61
+ ],
62
+ {
63
+ cwd: paths.home,
64
+ detached: true,
65
+ windowsHide: true,
66
+ env: process.env,
67
+ stdio: ["ignore", log, log],
68
+ },
69
+ );
70
+ child.unref();
71
+ await new Promise((resolve, reject) => {
72
+ child.once("spawn", resolve);
73
+ child.once("error", reject);
74
+ });
75
+ setTimeout(shutdown, 300);
76
+ return child.pid;
77
+ } finally {
78
+ closeSync(log);
79
+ }
80
+ },
81
+ });
40
82
  const host = process.env.HOST || "127.0.0.1";
41
83
  if (!["127.0.0.1", "localhost", "::1"].includes(host) && !auth.configured())
42
84
  throw new Error("绑定外网地址前请先在本机设置管理密码");
@@ -74,6 +116,7 @@ const app = createApp({
74
116
  syncChannel: () => channel.sync(),
75
117
  backup: () => backups.status(),
76
118
  cleanup,
119
+ transfer,
77
120
  shutdown,
78
121
  },
79
122
  });
@@ -89,12 +132,13 @@ function runMaintenance() {
89
132
  console.error(`后台维护失败:${error.message}`);
90
133
  }
91
134
  try {
92
- if (!cleanup.running) backups.tick();
135
+ if (!cleanup.running && !transfer.busy) backups.tick();
93
136
  } catch (error) {
94
137
  console.error(`自动备份调度失败:${error.message}`);
95
138
  }
96
139
  try {
97
- cleanup.tick();
140
+ if (!transfer.busy) cleanup.tick();
141
+ transfer.info();
98
142
  } catch (error) {
99
143
  console.error(`定时备份整理调度失败:${error.message}`);
100
144
  }
@@ -57,6 +57,16 @@ function mergeLegacyMemories(db) {
57
57
  }
58
58
  }
59
59
 
60
+ export const KNOWLEDGE_TRIGGERS = [
61
+ `CREATE TRIGGER IF NOT EXISTS core_vector_changed AFTER UPDATE OF embedding ON core_chunks BEGIN
62
+ DELETE FROM core_vector_buckets WHERE chunk_id=NEW.id;
63
+ UPDATE core_chunks SET vector_indexed=0 WHERE id=NEW.id;
64
+ END`,
65
+ `CREATE TRIGGER IF NOT EXISTS core_vector_deleted AFTER DELETE ON core_chunks BEGIN
66
+ DELETE FROM core_vector_buckets WHERE chunk_id=OLD.id;
67
+ END`,
68
+ ];
69
+
60
70
  export function migrateKnowledge(db) {
61
71
  db.exec(`
62
72
  CREATE TABLE IF NOT EXISTS core_collections (
@@ -124,14 +134,8 @@ export function migrateKnowledge(db) {
124
134
  db.exec(`CREATE INDEX IF NOT EXISTS core_chunks_collection ON core_chunks(collection_id, document_id);
125
135
  CREATE INDEX IF NOT EXISTS core_chunks_unindexed ON core_chunks(vector_indexed) WHERE embedding IS NOT NULL;
126
136
  CREATE TABLE IF NOT EXISTS core_vector_buckets(dimension INTEGER NOT NULL,band INTEGER NOT NULL,bucket INTEGER NOT NULL,chunk_id TEXT NOT NULL,PRIMARY KEY(dimension,band,bucket,chunk_id));
127
- CREATE INDEX IF NOT EXISTS core_vector_buckets_chunk ON core_vector_buckets(chunk_id);
128
- CREATE TRIGGER IF NOT EXISTS core_vector_changed AFTER UPDATE OF embedding ON core_chunks BEGIN
129
- DELETE FROM core_vector_buckets WHERE chunk_id=NEW.id;
130
- UPDATE core_chunks SET vector_indexed=0 WHERE id=NEW.id;
131
- END;
132
- CREATE TRIGGER IF NOT EXISTS core_vector_deleted AFTER DELETE ON core_chunks BEGIN
133
- DELETE FROM core_vector_buckets WHERE chunk_id=OLD.id;
134
- END;`);
137
+ CREATE INDEX IF NOT EXISTS core_vector_buckets_chunk ON core_vector_buckets(chunk_id);`);
138
+ db.exec(KNOWLEDGE_TRIGGERS.join(";") + ";");
135
139
  if (
136
140
  !db
137
141
  .prepare("SELECT id FROM core_collections WHERE id='shared-default'")
@@ -194,7 +194,7 @@ export function restoreToNewFile(source, destination) {
194
194
  }
195
195
  }
196
196
 
197
- export function prepareDatabaseMigration(file) {
197
+ export function prepareDatabaseMigration(file, { quiet = false } = {}) {
198
198
  if (file === ":memory:" || !existsSync(file)) return null;
199
199
  const db = new DatabaseSync(file, { readOnly: true });
200
200
  let version, hasData;
@@ -211,9 +211,10 @@ export function prepareDatabaseMigration(file) {
211
211
  db.close();
212
212
  }
213
213
  if (version === DATABASE_VERSION || !hasData) return null;
214
- console.log(
215
- `正在把数据库从 v${version} 升级到 v${DATABASE_VERSION}。会先复制整份数据,记录越多越久,请不要关闭窗口。`,
216
- );
214
+ if (!quiet)
215
+ console.log(
216
+ `正在把数据库从 v${version} 升级到 v${DATABASE_VERSION}。会先复制整份数据,记录越多越久,请不要关闭窗口。`,
217
+ );
217
218
  const directory = join(dirname(resolve(file)), "backups");
218
219
  mkdirSync(directory, { recursive: true });
219
220
  const target = join(
@@ -25,6 +25,7 @@ const DEFAULTS = Object.freeze({
25
25
  function kind(scope, name) {
26
26
  if (scope === "root")
27
27
  return /^friend\.pre-[\w-]+\.db$/.test(name) ? "snapshot" : null;
28
+ if (/^luckytri-before-import-\d+-[\da-f]{8}\.db$/.test(name)) return "import";
28
29
  if (/^luckytri-auto-\d{4}-\d\d-\d\dT[\d-]+Z\.db$/.test(name)) return "auto";
29
30
  if (/^luckytri-migration-v\d+-to-v\d+-\d+-[\da-f]{8}\.db$/.test(name))
30
31
  return "migration";
@@ -0,0 +1,298 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { randomUUID } from "node:crypto";
3
+ import {
4
+ createReadStream,
5
+ createWriteStream,
6
+ writeFileSync,
7
+ openSync,
8
+ readSync,
9
+ closeSync,
10
+ existsSync,
11
+ statSync,
12
+ renameSync,
13
+ mkdirSync,
14
+ rmSync,
15
+ } from "node:fs";
16
+ import { join, dirname, basename } from "node:path";
17
+ import { pipeline } from "node:stream/promises";
18
+ import {
19
+ copyDatabaseFile,
20
+ inspectDatabase,
21
+ writeArchiveManifest,
22
+ verifyArchive,
23
+ DATABASE_VERSION,
24
+ } from "./archive.js";
25
+ import { withBackupLock } from "./backup-lock.js";
26
+ import { createStore } from "./store.js";
27
+ import { Repository } from "../core/repository.js";
28
+ import { KNOWLEDGE_TRIGGERS } from "../knowledge/schema.js";
29
+
30
+ export const PACKAGE_MAGIC = Buffer.from("LUCKYTRI-DATA\n");
31
+ const SQLITE_MAGIC = Buffer.from("SQLite format 3\0");
32
+ export function packageHeader(manifest) {
33
+ const json = Buffer.from(JSON.stringify(manifest));
34
+ if (json.length > 262144) throw Error("数据清单过大");
35
+ const length = Buffer.alloc(4);
36
+ length.writeUInt32BE(json.length);
37
+ return Buffer.concat([PACKAGE_MAGIC, length, json]);
38
+ }
39
+
40
+ export function validateData(file) {
41
+ const result = inspectDatabase(file);
42
+ const db = new DatabaseSync(file, { readOnly: true });
43
+ try {
44
+ const canonical = (sql) =>
45
+ String(sql)
46
+ .replace(/\bIF\s+NOT\s+EXISTS\b/gi, "")
47
+ .replace(/\s+/g, "")
48
+ .replace(/;$/, "")
49
+ .toLowerCase();
50
+ const allowed = new Set(KNOWLEDGE_TRIGGERS.map(canonical));
51
+ if (
52
+ db
53
+ .prepare(
54
+ "SELECT type,sql FROM sqlite_master WHERE type IN ('trigger','view')",
55
+ )
56
+ .all()
57
+ .some(
58
+ (row) => row.type !== "trigger" || !allowed.has(canonical(row.sql)),
59
+ )
60
+ )
61
+ throw Error("数据文件包含不支持的数据库结构");
62
+ const settings = JSON.parse(
63
+ db.prepare("SELECT value FROM settings WHERE id=1").get()?.value ||
64
+ "null",
65
+ );
66
+ if (
67
+ !settings ||
68
+ typeof settings !== "object" ||
69
+ Array.isArray(settings) ||
70
+ typeof settings.name !== "string"
71
+ )
72
+ throw Error("不是有效的 LuckyTri 数据文件");
73
+ return { ...result, name: settings.name.slice(0, 100) };
74
+ } finally {
75
+ db.close();
76
+ }
77
+ }
78
+
79
+ export function makeDataExport(source, target, backups) {
80
+ return withBackupLock(backups, () => {
81
+ copyDatabaseFile(source, target);
82
+ const db = new DatabaseSync(target);
83
+ try {
84
+ for (const table of ["management_sessions", "management_auth"])
85
+ if (
86
+ db
87
+ .prepare(
88
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
89
+ )
90
+ .get(table)
91
+ )
92
+ db.exec(`DELETE FROM ${table}`);
93
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
94
+ } finally {
95
+ db.close();
96
+ }
97
+ const manifest = writeArchiveManifest(target, {
98
+ kind: "export",
99
+ filename: "data.db",
100
+ });
101
+ const summary = validateData(target);
102
+ return { manifest, summary, bytes: statSync(target).size };
103
+ });
104
+ }
105
+
106
+ export async function prepareDataImport(upload, target) {
107
+ const fd = openSync(upload, "r");
108
+ let offset = 0,
109
+ manifest = null;
110
+ try {
111
+ const start = Buffer.alloc(16);
112
+ const read = readSync(fd, start, 0, 16, 0);
113
+ if (read !== 16) throw Error("不是有效的 LuckyTri 数据文件");
114
+ if (!start.equals(SQLITE_MAGIC)) {
115
+ if (!start.subarray(0, PACKAGE_MAGIC.length).equals(PACKAGE_MAGIC))
116
+ throw Error("不是有效的 LuckyTri 数据文件");
117
+ const lengthBytes = Buffer.alloc(4);
118
+ if (readSync(fd, lengthBytes, 0, 4, PACKAGE_MAGIC.length) !== 4)
119
+ throw Error("数据包已损坏");
120
+ const length = lengthBytes.readUInt32BE();
121
+ if (length < 2 || length > 262144) throw Error("数据包已损坏");
122
+ const json = Buffer.alloc(length);
123
+ if (readSync(fd, json, 0, length, PACKAGE_MAGIC.length + 4) !== length)
124
+ throw Error("数据包已损坏");
125
+ manifest = JSON.parse(json.toString("utf8"));
126
+ if (
127
+ manifest.app !== "luckytri" ||
128
+ manifest.format !== 1 ||
129
+ !/^[a-f0-9]{64}$/.test(manifest.sha256)
130
+ )
131
+ throw Error("数据包已损坏");
132
+ offset = PACKAGE_MAGIC.length + 4 + length;
133
+ }
134
+ } finally {
135
+ closeSync(fd);
136
+ }
137
+ if (statSync(upload).size <= offset) throw Error("数据包已损坏");
138
+ await pipeline(
139
+ createReadStream(upload, { start: offset }),
140
+ createWriteStream(target, { flags: "wx", mode: 0o600 }),
141
+ );
142
+ if (manifest) {
143
+ writeFileSync(`${target}.json`, JSON.stringify(manifest), { mode: 0o600 });
144
+ verifyArchive(target);
145
+ }
146
+ const original = validateData(target);
147
+ // Upgrade only the staged copy; the running database is never opened here.
148
+ const store = createStore(target, { quietMigration: true });
149
+ try {
150
+ new Repository(store);
151
+ } finally {
152
+ store.db.close();
153
+ }
154
+ const migrated = validateData(target);
155
+ const checked = writeArchiveManifest(target, {
156
+ kind: "import",
157
+ filename: "data.db",
158
+ });
159
+ return {
160
+ summary: migrated,
161
+ originalVersion: original.schemaVersion,
162
+ manifest: checked,
163
+ bytes: statSync(target).size,
164
+ };
165
+ }
166
+
167
+ export function applyDataImport(
168
+ candidate,
169
+ source,
170
+ backups,
171
+ { id = randomUUID(), move = renameSync, beforeSwap = () => {} } = {},
172
+ ) {
173
+ if (source === ":memory:" || !existsSync(source))
174
+ throw Error("当前实例不支持文件导入");
175
+ return withBackupLock(backups, () => {
176
+ verifyArchive(candidate);
177
+ validateData(candidate);
178
+ const old = new DatabaseSync(source);
179
+ let auth, sessions, settings;
180
+ try {
181
+ settings = JSON.parse(
182
+ old.prepare("SELECT value FROM settings WHERE id=1").get().value,
183
+ );
184
+ auth = old
185
+ .prepare("SELECT salt,hash FROM management_auth WHERE id=1")
186
+ .get();
187
+ sessions = old
188
+ .prepare("SELECT id,expires FROM management_sessions")
189
+ .all();
190
+ const checkpoint = old.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get();
191
+ if (checkpoint.busy) throw Error("当前数据库仍在使用,请稍后导入");
192
+ } finally {
193
+ old.close();
194
+ }
195
+ if (existsSync(`${source}-wal`) || existsSync(`${source}-shm`))
196
+ throw Error("当前数据库仍在使用,请稍后导入");
197
+ const imported = new DatabaseSync(candidate);
198
+ try {
199
+ imported.exec("BEGIN IMMEDIATE");
200
+ imported.exec(
201
+ "DROP TABLE IF EXISTS management_sessions; DROP TABLE IF EXISTS management_auth; CREATE TABLE management_auth(id INTEGER PRIMARY KEY CHECK(id=1),salt TEXT NOT NULL,hash TEXT NOT NULL); CREATE TABLE management_sessions(id TEXT PRIMARY KEY,expires INTEGER NOT NULL);",
202
+ );
203
+ if (auth)
204
+ imported
205
+ .prepare("INSERT INTO management_auth VALUES (1,?,?)")
206
+ .run(auth.salt, auth.hash);
207
+ for (const row of sessions)
208
+ imported
209
+ .prepare("INSERT INTO management_sessions VALUES (?,?)")
210
+ .run(row.id, row.expires);
211
+ const incoming = JSON.parse(
212
+ imported.prepare("SELECT value FROM settings WHERE id=1").get().value,
213
+ );
214
+ for (const field of [
215
+ "onebotToken",
216
+ "qqbotAppId",
217
+ "qqbotSecret",
218
+ "channel",
219
+ "backupCleanup",
220
+ "backupCleanupLast",
221
+ ]) {
222
+ delete incoming[field];
223
+ if (settings[field] !== undefined) incoming[field] = settings[field];
224
+ }
225
+ incoming.enabled = false;
226
+ incoming.dataImportId = id;
227
+ imported
228
+ .prepare("UPDATE settings SET value=? WHERE id=1")
229
+ .run(JSON.stringify(incoming));
230
+ imported.exec(
231
+ "UPDATE plugin_installs SET enabled=0,granted='[]',path=''; COMMIT; PRAGMA wal_checkpoint(TRUNCATE);",
232
+ );
233
+ } catch (error) {
234
+ if (imported.isTransaction) imported.exec("ROLLBACK");
235
+ throw error;
236
+ } finally {
237
+ imported.close();
238
+ }
239
+ validateData(candidate);
240
+ mkdirSync(backups, { recursive: true });
241
+ const rollback = join(
242
+ backups,
243
+ `luckytri-before-import-${Date.now()}-${randomUUID().slice(0, 8)}.db`,
244
+ );
245
+ // Move the checkpointed original instead of creating another multi-GiB
246
+ // duplicate. The snapshot and its manifest remain available to restore.
247
+ writeArchiveManifest(source, {
248
+ kind: "before-import",
249
+ filename: basename(rollback),
250
+ });
251
+ beforeSwap(basename(rollback));
252
+ let original = rollback;
253
+ try {
254
+ move(source, rollback);
255
+ } catch (error) {
256
+ if (error.code !== "EXDEV") throw error;
257
+ // Custom backup folders may be on another disk. Retain one verified
258
+ // snapshot there, and keep the swap on the database's own filesystem.
259
+ copyDatabaseFile(source, rollback);
260
+ writeArchiveManifest(rollback, {
261
+ kind: "before-import",
262
+ filename: basename(rollback),
263
+ });
264
+ original = `${source}.import-swap-${randomUUID()}`;
265
+ move(source, original);
266
+ }
267
+ try {
268
+ if (original === rollback) move(`${source}.json`, `${rollback}.json`);
269
+ try {
270
+ move(candidate, source);
271
+ } catch (error) {
272
+ if (error.code !== "EXDEV") throw error;
273
+ const local = join(
274
+ dirname(source),
275
+ `.luckytri-import-${randomUUID()}.partial.db`,
276
+ );
277
+ try {
278
+ copyDatabaseFile(candidate, local);
279
+ validateData(local);
280
+ move(local, source);
281
+ rmSync(candidate, { force: true });
282
+ } finally {
283
+ rmSync(local, { force: true });
284
+ }
285
+ }
286
+ } catch (error) {
287
+ if (!existsSync(source)) renameSync(original, source);
288
+ if (existsSync(`${rollback}.json`))
289
+ renameSync(`${rollback}.json`, `${source}.json`);
290
+ throw error;
291
+ }
292
+ if (original !== rollback) {
293
+ rmSync(original, { force: true });
294
+ rmSync(`${source}.json`, { force: true });
295
+ }
296
+ return { backup: basename(rollback), schemaVersion: DATABASE_VERSION };
297
+ });
298
+ }