dscs-server 0.1.3 → 0.1.5
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.
- package/README.md +59 -17
- package/dist/admin-password.js +27 -0
- package/dist/audit.js +73 -0
- package/dist/db.js +174 -3
- package/dist/http.js +433 -85
- package/dist/index.js +23 -7
- package/dist/session-admin.js +214 -0
- package/dist/session-history.js +134 -0
- package/dist/sessions.js +133 -73
- package/dist/shared/blob-batch.js +4 -2
- package/dist/shared/wire.js +1 -0
- package/package.json +1 -1
- package/web/admin.html +1833 -297
package/README.md
CHANGED
|
@@ -2,38 +2,80 @@
|
|
|
2
2
|
|
|
3
3
|
DSH Session CloudSync 的自部署服务端。
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
旧的 `/v1/*` 已移除;0.0.x 插件不兼容,需要 0.1.0 起的插件。
|
|
5
|
+
当前发布版本:**0.1.5**(协议版本 3,设备 API 只有 `/v2/*`;数据库结构版本 6)。
|
|
7
6
|
|
|
8
|
-
##
|
|
7
|
+
## 能做什么
|
|
9
8
|
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
9
|
+
- **多设备接续**:把 DSH 会话同步到自己的服务器,在另一台设备继续;
|
|
10
|
+
- **私有部署**:数据留在自己的机器或 NAS,不经过第三方;
|
|
11
|
+
- **端到端加密**:服务端只保存不透明密文和同步所需的基本信息,看不到会话内容;
|
|
12
|
+
- **弱网可用**:断点续传、批量传输、加密前压缩,网络恢复后继续;
|
|
13
|
+
- **附件通道**:加密附件(图片/文件)与按批次的附件索引有独立的 blob 类型,会话移除时一并释放;插件侧接入后生效;
|
|
14
|
+
- **不会互相覆盖**:同一时间只允许一台设备上传,避免多端同时写乱;
|
|
15
|
+
- **设备管理**:签发/改名/吊销设备,查看最后在线时间,为设备设置颜色;
|
|
16
|
+
- **会话管理**:存档、移除并释放空间、重置后重新上传、恢复存档;
|
|
17
|
+
- **审计**:记录登录、设备、配额、密码和会话管理动作,不记录内容或密钥;
|
|
18
|
+
- **历史与活动**:查看单个会话的变更时间线,以及全局的每日同步强度热力图。
|
|
16
19
|
|
|
17
20
|
## 安装
|
|
18
21
|
|
|
22
|
+
### 一键安装(推荐)
|
|
23
|
+
|
|
24
|
+
Linux / macOS:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
curl -fsSL https://raw.githubusercontent.com/xfy2412/dsh-session-cloudsync/main/scripts/install.sh -o install.sh
|
|
28
|
+
less install.sh # 建议先看一眼脚本
|
|
29
|
+
sh install.sh
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Windows PowerShell:
|
|
33
|
+
|
|
34
|
+
```powershell
|
|
35
|
+
irm https://raw.githubusercontent.com/xfy2412/dsh-session-cloudsync/main/scripts/install.ps1 -OutFile install.ps1
|
|
36
|
+
notepad install.ps1 # 建议先看一眼脚本
|
|
37
|
+
.\install.ps1
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
安装脚本会把版本装到独立目录,并可选注册 systemd 或计划任务;支持
|
|
41
|
+
`--version`、`--upgrade`、`--uninstall`、`--purge` 等参数。
|
|
42
|
+
|
|
43
|
+
### 手动安装
|
|
44
|
+
|
|
19
45
|
```sh
|
|
20
46
|
npm i -g dscs-server
|
|
21
47
|
dscs-server serve --data-dir /var/lib/dscs --host 127.0.0.1 --port 8787
|
|
22
48
|
```
|
|
23
49
|
|
|
24
|
-
首次启动会在控制台打印一次性初始化密钥,同时写入数据目录的 `onboarding-code.json
|
|
50
|
+
首次启动会在控制台打印一次性初始化密钥,同时写入数据目录的 `onboarding-code.json`。
|
|
51
|
+
打开 `http://127.0.0.1:8787/admin` 完成管理密码、配额和设备初始化。
|
|
52
|
+
|
|
53
|
+
## CLI
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
dscs-server serve --data-dir /var/lib/dscs --host 127.0.0.1 --port 8787
|
|
57
|
+
dscs-server token add laptop
|
|
58
|
+
dscs-server token list
|
|
59
|
+
dscs-server token revoke <deviceId>
|
|
60
|
+
dscs-server admin status
|
|
61
|
+
dscs-server admin reset-password --data-dir /var/lib/dscs
|
|
62
|
+
printf '%s' "$NEW_PASSWORD" | dscs-server admin reset-password --data-dir /var/lib/dscs --stdin
|
|
63
|
+
```
|
|
25
64
|
|
|
26
|
-
|
|
65
|
+
`admin reset-password` 默认生成并打印一次随机密码;`--stdin` 读取自定义密码。
|
|
66
|
+
重置后需重启 server 让旧管理会话失效;在管理台修改密码会立即失效其它会话。
|
|
27
67
|
|
|
28
|
-
##
|
|
68
|
+
## 要求与迁移
|
|
29
69
|
|
|
30
70
|
- Node.js >= 24;
|
|
31
71
|
- 默认只监听 `127.0.0.1`,公网部署请放在 TLS 反代后;
|
|
32
|
-
- 数据目录包含 SQLite
|
|
33
|
-
-
|
|
34
|
-
- 0.
|
|
35
|
-
-
|
|
36
|
-
-
|
|
72
|
+
- 数据目录包含 SQLite、blob 文件与迁移备份,升级前请先备份;
|
|
73
|
+
- 数据库结构版本 6,支持从旧版本自动迁移,迁移前会写本地备份;
|
|
74
|
+
- 0.1.5 不迁移数据库,可直接回滚;旧插件不受影响(附件功能按 `/health` 的 `attachment-index` capability 门控);
|
|
75
|
+
- 旧的变更流接口已移除;0.1.0 插件不兼容,需要插件 >= 0.1.1;
|
|
76
|
+
- 服务端不持有明文会话内容,只存不透明密文和同步元数据;
|
|
77
|
+
- 已有未压缩会话不会自动压缩;需要清空数据目录后由插件全量重传;
|
|
78
|
+
- 协议说明见 `docs/architecture/PROTOCOL.md`,错误分类见 `docs/architecture/ERRORS.md`。
|
|
37
79
|
|
|
38
80
|
## 许可证
|
|
39
81
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { deriveMasterKey, newSalt } from './shared/crypto.js';
|
|
3
|
+
import { recordAudit } from './audit.js';
|
|
4
|
+
import { isAdminInitialized, setAdminPassword } from './db.js';
|
|
5
|
+
import { validateAdminPassword } from './http.js';
|
|
6
|
+
export function generateAdminPassword() {
|
|
7
|
+
for(let attempt = 0; attempt < 10; attempt += 1){
|
|
8
|
+
const candidate = randomBytes(18).toString('base64url');
|
|
9
|
+
if (validateAdminPassword(candidate) === undefined) return candidate;
|
|
10
|
+
}
|
|
11
|
+
return randomBytes(18).toString('base64url') + '!aA1';
|
|
12
|
+
}
|
|
13
|
+
export function resetAdminPassword(db, password) {
|
|
14
|
+
if (!isAdminInitialized(db)) throw new Error('尚未初始化,请先通过 /admin 完成初始化');
|
|
15
|
+
const problem = validateAdminPassword(password);
|
|
16
|
+
if (problem !== undefined) throw new Error(problem);
|
|
17
|
+
const salt = newSalt();
|
|
18
|
+
setAdminPassword(db, deriveMasterKey(password, salt).toString('hex'), salt.toString('hex'));
|
|
19
|
+
recordAudit(db, {
|
|
20
|
+
actorKind: 'system',
|
|
21
|
+
actorId: 'cli',
|
|
22
|
+
action: 'password.reset_cli'
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
//# sourceURL=admin-password.ts
|
package/dist/audit.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { DEFAULT_DATASET } from './db.js';
|
|
2
|
+
function requestMeta(req) {
|
|
3
|
+
if (req === undefined) return {
|
|
4
|
+
ip: null,
|
|
5
|
+
userAgent: null
|
|
6
|
+
};
|
|
7
|
+
const forwarded = req.headers['x-forwarded-for'];
|
|
8
|
+
const first = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
|
9
|
+
const ip = first !== undefined && first.trim() !== '' ? first.split(',')[0].trim() : req.socket.remoteAddress ?? null;
|
|
10
|
+
const agent = req.headers['user-agent'];
|
|
11
|
+
const userAgent = typeof agent === 'string' && agent !== '' ? agent.slice(0, 300) : null;
|
|
12
|
+
return {
|
|
13
|
+
ip,
|
|
14
|
+
userAgent
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function recordAudit(db, input, req) {
|
|
18
|
+
try {
|
|
19
|
+
const meta = requestMeta(req);
|
|
20
|
+
db.handle.prepare(`INSERT INTO audit_events (dataset, at, actor_kind, actor_id, action, target, detail, ip, user_agent)
|
|
21
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(DEFAULT_DATASET, Date.now(), input.actorKind, input.actorId, input.action, input.target ?? null, input.detail === undefined ? null : JSON.stringify(input.detail), meta.ip, meta.userAgent);
|
|
22
|
+
} catch {}
|
|
23
|
+
}
|
|
24
|
+
export function listAudit(db, query = {}) {
|
|
25
|
+
const limit = Math.min(200, Math.max(1, Math.trunc(query.limit ?? 50)));
|
|
26
|
+
const where = [
|
|
27
|
+
'e.dataset = ?'
|
|
28
|
+
];
|
|
29
|
+
const params = [
|
|
30
|
+
DEFAULT_DATASET
|
|
31
|
+
];
|
|
32
|
+
if (query.before !== undefined && Number.isSafeInteger(query.before)) {
|
|
33
|
+
where.push('e.id < ?');
|
|
34
|
+
params.push(query.before);
|
|
35
|
+
}
|
|
36
|
+
if (query.action !== undefined && query.action !== '') {
|
|
37
|
+
where.push('e.action = ?');
|
|
38
|
+
params.push(query.action);
|
|
39
|
+
}
|
|
40
|
+
params.push(limit);
|
|
41
|
+
const rows = db.handle.prepare(`SELECT e.id, e.at, e.actor_kind, e.actor_id, e.action, e.target, e.detail, e.ip, e.user_agent,
|
|
42
|
+
d.label AS actor_label
|
|
43
|
+
FROM audit_events e
|
|
44
|
+
LEFT JOIN devices d ON d.dataset = e.dataset AND d.device_id = e.actor_id
|
|
45
|
+
WHERE ${where.join(' AND ')}
|
|
46
|
+
ORDER BY e.id DESC LIMIT ?`).all(...params);
|
|
47
|
+
const events = rows.map((row)=>({
|
|
48
|
+
id: row.id,
|
|
49
|
+
at: row.at,
|
|
50
|
+
actorKind: row.actor_kind,
|
|
51
|
+
actorId: row.actor_id,
|
|
52
|
+
actorLabel: row.actor_kind === 'admin' ? '管理台' : row.actor_kind === 'system' ? '系统' : row.actor_label ?? row.actor_id,
|
|
53
|
+
action: row.action,
|
|
54
|
+
target: row.target,
|
|
55
|
+
detail: row.detail === null ? null : parseDetail(row.detail),
|
|
56
|
+
ip: row.ip,
|
|
57
|
+
userAgent: row.user_agent
|
|
58
|
+
}));
|
|
59
|
+
return {
|
|
60
|
+
events,
|
|
61
|
+
nextBefore: rows.length === limit ? rows[rows.length - 1].id : null
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function parseDetail(raw) {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(raw);
|
|
67
|
+
} catch {
|
|
68
|
+
return raw;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
//# sourceURL=audit.ts
|
package/dist/db.js
CHANGED
|
@@ -3,7 +3,7 @@ import { mkdirSync } from 'node:fs';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { DatabaseSync } from 'node:sqlite';
|
|
5
5
|
export const DEFAULT_DATASET = 'default';
|
|
6
|
-
export const SERVER_SCHEMA_VERSION =
|
|
6
|
+
export const SERVER_SCHEMA_VERSION = 6;
|
|
7
7
|
const SCHEMA_VERSION = SERVER_SCHEMA_VERSION;
|
|
8
8
|
const SCHEMA = `
|
|
9
9
|
CREATE TABLE IF NOT EXISTS meta (
|
|
@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS devices (
|
|
|
19
19
|
created_at INTEGER NOT NULL,
|
|
20
20
|
last_seen_at INTEGER,
|
|
21
21
|
revoked_at INTEGER,
|
|
22
|
+
color TEXT,
|
|
22
23
|
PRIMARY KEY (dataset, device_id)
|
|
23
24
|
);
|
|
24
25
|
|
|
@@ -35,6 +36,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
35
36
|
revision INTEGER NOT NULL,
|
|
36
37
|
updated_at INTEGER NOT NULL,
|
|
37
38
|
deleted_at INTEGER,
|
|
39
|
+
purged_at INTEGER,
|
|
38
40
|
compression TEXT NOT NULL DEFAULT 'none',
|
|
39
41
|
stored_bytes INTEGER NOT NULL DEFAULT 0,
|
|
40
42
|
PRIMARY KEY (dataset, session_id)
|
|
@@ -91,6 +93,55 @@ CREATE TABLE IF NOT EXISTS tombstones (
|
|
|
91
93
|
deleted_by TEXT NOT NULL,
|
|
92
94
|
PRIMARY KEY (dataset, session_id, created_at)
|
|
93
95
|
);
|
|
96
|
+
|
|
97
|
+
CREATE TABLE IF NOT EXISTS audit_events (
|
|
98
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
99
|
+
dataset TEXT NOT NULL,
|
|
100
|
+
at INTEGER NOT NULL,
|
|
101
|
+
actor_kind TEXT NOT NULL,
|
|
102
|
+
actor_id TEXT NOT NULL,
|
|
103
|
+
action TEXT NOT NULL,
|
|
104
|
+
target TEXT,
|
|
105
|
+
detail TEXT,
|
|
106
|
+
ip TEXT,
|
|
107
|
+
user_agent TEXT
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
CREATE INDEX IF NOT EXISTS audit_events_by_time ON audit_events (dataset, at DESC);
|
|
111
|
+
CREATE INDEX IF NOT EXISTS audit_events_by_action ON audit_events (dataset, action, at DESC);
|
|
112
|
+
|
|
113
|
+
CREATE TABLE IF NOT EXISTS session_resets (
|
|
114
|
+
dataset TEXT NOT NULL,
|
|
115
|
+
session_id TEXT NOT NULL,
|
|
116
|
+
created_at INTEGER NOT NULL,
|
|
117
|
+
reset_at INTEGER NOT NULL,
|
|
118
|
+
expires_at INTEGER NOT NULL,
|
|
119
|
+
device_id TEXT,
|
|
120
|
+
workspace_key TEXT,
|
|
121
|
+
PRIMARY KEY (dataset, session_id)
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
CREATE TABLE IF NOT EXISTS session_events (
|
|
125
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
126
|
+
dataset TEXT NOT NULL,
|
|
127
|
+
session_id TEXT NOT NULL,
|
|
128
|
+
at INTEGER NOT NULL,
|
|
129
|
+
kind TEXT NOT NULL,
|
|
130
|
+
device_id TEXT,
|
|
131
|
+
actor_kind TEXT NOT NULL,
|
|
132
|
+
actor_id TEXT NOT NULL DEFAULT '',
|
|
133
|
+
events INTEGER NOT NULL DEFAULT 0,
|
|
134
|
+
bytes INTEGER NOT NULL DEFAULT 0,
|
|
135
|
+
seq_from INTEGER,
|
|
136
|
+
seq_to INTEGER,
|
|
137
|
+
batch_count INTEGER NOT NULL DEFAULT 0,
|
|
138
|
+
last_seq INTEGER,
|
|
139
|
+
stored_bytes INTEGER,
|
|
140
|
+
detail TEXT
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
CREATE INDEX IF NOT EXISTS session_events_by_session ON session_events (dataset, session_id, at);
|
|
144
|
+
CREATE INDEX IF NOT EXISTS session_events_by_time ON session_events (dataset, at);
|
|
94
145
|
`;
|
|
95
146
|
function tableExists(handle, table) {
|
|
96
147
|
const row = handle.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
|
@@ -161,18 +212,133 @@ function ensureSessionStatsColumns(handle) {
|
|
|
161
212
|
if (added) backfillSessionStats(handle);
|
|
162
213
|
return added;
|
|
163
214
|
}
|
|
215
|
+
export const DEVICE_COLORS = [
|
|
216
|
+
'#5b88ff',
|
|
217
|
+
'#5ed392',
|
|
218
|
+
'#f0b64a',
|
|
219
|
+
'#c084fc',
|
|
220
|
+
'#38bdf8',
|
|
221
|
+
'#f472b6',
|
|
222
|
+
'#a3e635',
|
|
223
|
+
'#fb923c',
|
|
224
|
+
'#94a3b8',
|
|
225
|
+
'#f87171'
|
|
226
|
+
];
|
|
227
|
+
export function nextDeviceColor(db) {
|
|
228
|
+
const rows = db.handle.prepare('SELECT color FROM devices WHERE dataset = ? ORDER BY created_at').all(DEFAULT_DATASET);
|
|
229
|
+
const used = new Set(rows.map((row)=>row.color).filter((color)=>typeof color === 'string' && color !== ''));
|
|
230
|
+
const free = DEVICE_COLORS.find((color)=>!used.has(color));
|
|
231
|
+
return free ?? DEVICE_COLORS[rows.length % DEVICE_COLORS.length];
|
|
232
|
+
}
|
|
233
|
+
function backfillDeviceColors(handle) {
|
|
234
|
+
const rows = handle.prepare('SELECT device_id, color FROM devices ORDER BY created_at, device_id').all();
|
|
235
|
+
const used = new Set(rows.map((row)=>row.color).filter((color)=>typeof color === 'string' && color !== ''));
|
|
236
|
+
const update = handle.prepare('UPDATE devices SET color = ? WHERE device_id = ?');
|
|
237
|
+
let next = 0;
|
|
238
|
+
for (const row of rows){
|
|
239
|
+
if (typeof row.color === 'string' && row.color !== '') continue;
|
|
240
|
+
let color;
|
|
241
|
+
while(next < DEVICE_COLORS.length){
|
|
242
|
+
const candidate = DEVICE_COLORS[next];
|
|
243
|
+
next += 1;
|
|
244
|
+
if (candidate !== undefined && !used.has(candidate)) {
|
|
245
|
+
color = candidate;
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
color ??= DEVICE_COLORS[next % DEVICE_COLORS.length] ?? DEVICE_COLORS[0];
|
|
250
|
+
used.add(color);
|
|
251
|
+
update.run(color, row.device_id);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function ensureDeviceColorColumn(handle) {
|
|
255
|
+
const columns = tableColumns(handle, 'devices');
|
|
256
|
+
let added = false;
|
|
257
|
+
if (!columns.has('color')) {
|
|
258
|
+
handle.exec('ALTER TABLE devices ADD COLUMN color TEXT');
|
|
259
|
+
added = true;
|
|
260
|
+
}
|
|
261
|
+
backfillDeviceColors(handle);
|
|
262
|
+
return added;
|
|
263
|
+
}
|
|
264
|
+
function ensureSessionLifecycleColumns(handle) {
|
|
265
|
+
const columns = tableColumns(handle, 'sessions');
|
|
266
|
+
if (columns.has('purged_at')) return false;
|
|
267
|
+
handle.exec('ALTER TABLE sessions ADD COLUMN purged_at INTEGER');
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
function ensureSessionResetColumns(handle) {
|
|
271
|
+
if (!tableExists(handle, 'session_resets')) return false;
|
|
272
|
+
const columns = tableColumns(handle, 'session_resets');
|
|
273
|
+
if (columns.has('workspace_key')) return false;
|
|
274
|
+
handle.exec('ALTER TABLE session_resets ADD COLUMN workspace_key TEXT');
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
function ensureSessionEventColumns(handle) {
|
|
278
|
+
if (!tableExists(handle, 'session_events')) return false;
|
|
279
|
+
const columns = tableColumns(handle, 'session_events');
|
|
280
|
+
if (columns.has('actor_id')) return false;
|
|
281
|
+
handle.exec("ALTER TABLE session_events ADD COLUMN actor_id TEXT NOT NULL DEFAULT ''");
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
function backfillSessionEvents(handle) {
|
|
285
|
+
const marker = handle.prepare("SELECT value FROM meta WHERE key = 'session_events_backfill'").get();
|
|
286
|
+
if (marker !== undefined) return;
|
|
287
|
+
const existing = handle.prepare('SELECT COUNT(*) AS n FROM session_events').get();
|
|
288
|
+
if (Number(existing?.n ?? 0) > 0) {
|
|
289
|
+
handle.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('session_events_backfill', '1')").run();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (!tableExists(handle, 'append_requests')) return;
|
|
293
|
+
const rows = handle.prepare(`SELECT session_id, device_id, created_at, result_json
|
|
294
|
+
FROM append_requests WHERE dataset = ?
|
|
295
|
+
ORDER BY session_id ASC, created_at ASC, rowid ASC`).all(DEFAULT_DATASET);
|
|
296
|
+
const state = new Map();
|
|
297
|
+
const insert = handle.prepare(`INSERT INTO session_events
|
|
298
|
+
(dataset, session_id, at, kind, device_id, actor_kind, actor_id, events, bytes, seq_from, seq_to, batch_count, last_seq, stored_bytes, detail)
|
|
299
|
+
VALUES (?, ?, ?, ?, ?, 'device', ?, ?, ?, NULL, NULL, 1, ?, ?, ?)`);
|
|
300
|
+
for (const row of rows){
|
|
301
|
+
let summary;
|
|
302
|
+
try {
|
|
303
|
+
summary = JSON.parse(row.result_json);
|
|
304
|
+
} catch {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (typeof summary.lastSeq !== 'number' || !Number.isSafeInteger(summary.lastSeq)) continue;
|
|
308
|
+
const storedBytes = typeof summary.storedBytes === 'number' && Number.isSafeInteger(summary.storedBytes) ? summary.storedBytes : 0;
|
|
309
|
+
const previous = state.get(row.session_id);
|
|
310
|
+
const oldSeq = previous?.seen === true ? previous.lastSeq : 0;
|
|
311
|
+
const oldBytes = previous?.seen === true ? previous.storedBytes : 0;
|
|
312
|
+
const events = Math.max(0, summary.lastSeq - oldSeq);
|
|
313
|
+
const bytes = Math.max(0, storedBytes - oldBytes);
|
|
314
|
+
insert.run(DEFAULT_DATASET, row.session_id, row.created_at, previous?.seen === true ? 'append' : 'create', row.device_id, row.device_id, events, bytes, summary.lastSeq, storedBytes, JSON.stringify({
|
|
315
|
+
backfilled: true
|
|
316
|
+
}));
|
|
317
|
+
state.set(row.session_id, {
|
|
318
|
+
lastSeq: summary.lastSeq,
|
|
319
|
+
storedBytes,
|
|
320
|
+
seen: true
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
handle.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('session_events_backfill', '1')").run();
|
|
324
|
+
}
|
|
164
325
|
function migrateDatabase(handle, dataDir, version) {
|
|
165
326
|
if (version > SCHEMA_VERSION) {
|
|
166
327
|
throw new Error(`数据库结构版本 ${version} 高于服务端支持的 ${SCHEMA_VERSION};请升级服务端,不要降级覆盖。`);
|
|
167
328
|
}
|
|
168
329
|
if (version === SCHEMA_VERSION) {
|
|
169
330
|
handle.exec(SCHEMA);
|
|
331
|
+
ensureDeviceColorColumn(handle);
|
|
332
|
+
ensureSessionResetColumns(handle);
|
|
333
|
+
ensureSessionEventColumns(handle);
|
|
334
|
+
backfillSessionEvents(handle);
|
|
170
335
|
const columns = tableColumns(handle, 'sessions');
|
|
171
|
-
if (!columns.has('compression') || !columns.has('stored_bytes')) {
|
|
336
|
+
if (!columns.has('compression') || !columns.has('stored_bytes') || !columns.has('purged_at')) {
|
|
172
337
|
backupBeforeMigration(handle, dataDir, version);
|
|
173
338
|
handle.exec('BEGIN IMMEDIATE');
|
|
174
339
|
try {
|
|
175
340
|
ensureSessionStatsColumns(handle);
|
|
341
|
+
ensureSessionLifecycleColumns(handle);
|
|
176
342
|
handle.exec('COMMIT');
|
|
177
343
|
} catch (error) {
|
|
178
344
|
handle.exec('ROLLBACK');
|
|
@@ -198,7 +364,7 @@ function migrateDatabase(handle, dataDir, version) {
|
|
|
198
364
|
}
|
|
199
365
|
sourceVersion = 2;
|
|
200
366
|
}
|
|
201
|
-
if (sourceVersion !== 2 && sourceVersion !== 3) {
|
|
367
|
+
if (sourceVersion !== 2 && sourceVersion !== 3 && sourceVersion !== 4 && sourceVersion !== 5) {
|
|
202
368
|
throw new Error(`不支持的数据库结构版本 ${sourceVersion};请使用对应版本的服务端迁移。`);
|
|
203
369
|
}
|
|
204
370
|
assertV2Sessions(handle);
|
|
@@ -211,6 +377,11 @@ function migrateDatabase(handle, dataDir, version) {
|
|
|
211
377
|
}
|
|
212
378
|
handle.exec(SCHEMA);
|
|
213
379
|
ensureSessionStatsColumns(handle);
|
|
380
|
+
ensureDeviceColorColumn(handle);
|
|
381
|
+
ensureSessionLifecycleColumns(handle);
|
|
382
|
+
ensureSessionResetColumns(handle);
|
|
383
|
+
ensureSessionEventColumns(handle);
|
|
384
|
+
backfillSessionEvents(handle);
|
|
214
385
|
handle.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
215
386
|
handle.exec('COMMIT');
|
|
216
387
|
} catch (error) {
|