dscs-server 0.0.0-stage → 0.0.1
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/LICENSE +661 -0
- package/README.md +25 -2
- package/dist/blobs.js +50 -0
- package/dist/db.js +262 -0
- package/dist/http.js +1030 -0
- package/dist/index.js +170 -0
- package/dist/leases.js +43 -0
- package/dist/sessions.js +218 -0
- package/dist/shared/batches.js +214 -0
- package/dist/shared/chain.js +139 -0
- package/dist/shared/crypto.js +70 -0
- package/dist/shared/frames.js +102 -0
- package/dist/shared/index.js +11 -0
- package/dist/shared/protocol.js +14 -0
- package/dist/shared/sealed-frame.js +118 -0
- package/dist/shared/vault.js +210 -0
- package/dist/shared/wire.js +25 -0
- package/dist/vault.js +147 -0
- package/package.json +22 -4
- package/web/admin.html +895 -0
- package/web/setup.html +591 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { ONBOARDING_CODE_FILE, ONBOARDING_TTL_MS, createDevice, createHttpServer, listDevices, mintOnboardingCode, revokeDevice, DEFAULT_MAX_BYTES } from './http.js';
|
|
5
|
+
import { clearAdminPassword, ensureSetupToken, isAdminInitialized, openDatabase, readMaxBytes } from './db.js';
|
|
6
|
+
function writeOnboardingCode(dataDir, code) {
|
|
7
|
+
mkdirSync(dataDir, {
|
|
8
|
+
recursive: true
|
|
9
|
+
});
|
|
10
|
+
const file = join(dataDir, ONBOARDING_CODE_FILE);
|
|
11
|
+
const temporary = `${file}.tmp`;
|
|
12
|
+
writeFileSync(temporary, `${JSON.stringify({
|
|
13
|
+
code,
|
|
14
|
+
expiresAt: Date.now() + ONBOARDING_TTL_MS
|
|
15
|
+
})}\n`, {
|
|
16
|
+
mode: 0o600
|
|
17
|
+
});
|
|
18
|
+
try {
|
|
19
|
+
chmodSync(temporary, 0o600);
|
|
20
|
+
} catch {}
|
|
21
|
+
renameSync(temporary, file);
|
|
22
|
+
}
|
|
23
|
+
function clearOnboardingCode(dataDir) {
|
|
24
|
+
rmSync(join(dataDir, ONBOARDING_CODE_FILE), {
|
|
25
|
+
force: true
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function usage() {
|
|
29
|
+
console.error(`dsh-session-cloudsync-server <命令> [选项]
|
|
30
|
+
|
|
31
|
+
命令:
|
|
32
|
+
serve 启动 HTTP API 与管理台
|
|
33
|
+
token add <标签> 签发一个设备 token(只打印一次)
|
|
34
|
+
token list 列出存活设备
|
|
35
|
+
token revoke <deviceId> 吊销某个设备 token
|
|
36
|
+
admin status 查看初始化状态与配额
|
|
37
|
+
admin reset 忘记管理密码时重置(重启 serve 后重新初始化)
|
|
38
|
+
|
|
39
|
+
选项:
|
|
40
|
+
--data-dir <目录> 数据目录(默认 .tmp/dev-data)
|
|
41
|
+
--host <地址> 绑定地址(默认 127.0.0.1)
|
|
42
|
+
--port <端口> 监听端口(默认 8787)
|
|
43
|
+
--max-bytes <n> 存储上限(默认 10 GiB)
|
|
44
|
+
`);
|
|
45
|
+
process.exit(2);
|
|
46
|
+
}
|
|
47
|
+
function parse(argv) {
|
|
48
|
+
const positionals = [];
|
|
49
|
+
const flags = new Map();
|
|
50
|
+
for(let index = 0; index < argv.length; index += 1){
|
|
51
|
+
const argument = argv[index];
|
|
52
|
+
if (argument.startsWith('--')) {
|
|
53
|
+
const value = argv[index + 1];
|
|
54
|
+
if (value === undefined || value.startsWith('--')) usage();
|
|
55
|
+
flags.set(argument.slice(2), value);
|
|
56
|
+
index += 1;
|
|
57
|
+
} else {
|
|
58
|
+
positionals.push(argument);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const command = positionals.shift();
|
|
62
|
+
if (command === undefined) usage();
|
|
63
|
+
return {
|
|
64
|
+
command,
|
|
65
|
+
positionals,
|
|
66
|
+
flags
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
async function main(parsed) {
|
|
70
|
+
const dataDir = parsed.flags.get('data-dir') ?? '.tmp/dev-data';
|
|
71
|
+
const db = openDatabase(dataDir);
|
|
72
|
+
try {
|
|
73
|
+
if (parsed.command === 'token') {
|
|
74
|
+
const action = parsed.positionals[0];
|
|
75
|
+
if (action === 'add') {
|
|
76
|
+
const label = parsed.positionals[1] ?? 'device';
|
|
77
|
+
const created = createDevice(db, label);
|
|
78
|
+
console.log(JSON.stringify(created, null, 2));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (action === 'list') {
|
|
82
|
+
console.log(JSON.stringify(listDevices(db), null, 2));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (action === 'revoke') {
|
|
86
|
+
const deviceId = parsed.positionals[1];
|
|
87
|
+
if (deviceId === undefined) usage();
|
|
88
|
+
console.log(revokeDevice(db, deviceId) ? '已吊销' : '没有这个设备');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
usage();
|
|
92
|
+
}
|
|
93
|
+
if (parsed.command === 'admin') {
|
|
94
|
+
const action = parsed.positionals[0];
|
|
95
|
+
if (action === 'status') {
|
|
96
|
+
console.log(JSON.stringify({
|
|
97
|
+
initialized: isAdminInitialized(db),
|
|
98
|
+
maxBytes: readMaxBytes(db, DEFAULT_MAX_BYTES)
|
|
99
|
+
}, null, 2));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (action === 'reset') {
|
|
103
|
+
clearAdminPassword(db);
|
|
104
|
+
clearOnboardingCode(dataDir);
|
|
105
|
+
console.log('已清除管理密码。重启 server 后会在控制台打印新的初始化密钥。');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
usage();
|
|
109
|
+
}
|
|
110
|
+
if (parsed.command === 'serve') {
|
|
111
|
+
const host = parsed.flags.get('host') ?? '127.0.0.1';
|
|
112
|
+
const port = Number(parsed.flags.get('port') ?? '8787');
|
|
113
|
+
const maxBytes = Number(parsed.flags.get('max-bytes') ?? DEFAULT_MAX_BYTES);
|
|
114
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) usage();
|
|
115
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) usage();
|
|
116
|
+
const setup = ensureSetupToken(db);
|
|
117
|
+
writeFileSync(join(dataDir, 'setup-token'), `${setup.token}\n`, {
|
|
118
|
+
mode: 0o600
|
|
119
|
+
});
|
|
120
|
+
try {
|
|
121
|
+
chmodSync(join(dataDir, 'setup-token'), 0o600);
|
|
122
|
+
} catch {}
|
|
123
|
+
const initialized = isAdminInitialized(db);
|
|
124
|
+
const onboardingCode = initialized ? undefined : mintOnboardingCode();
|
|
125
|
+
if (onboardingCode === undefined) clearOnboardingCode(dataDir);
|
|
126
|
+
else writeOnboardingCode(dataDir, onboardingCode);
|
|
127
|
+
const server = createHttpServer({
|
|
128
|
+
db,
|
|
129
|
+
dataDir,
|
|
130
|
+
maxBytes,
|
|
131
|
+
...onboardingCode === undefined ? {} : {
|
|
132
|
+
onboardingCode
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
server.listen(port, host, ()=>{
|
|
136
|
+
const address = server.address();
|
|
137
|
+
const actualPort = typeof address === 'object' && address !== null ? address.port : port;
|
|
138
|
+
console.log(`dsh-session-cloudsync-server 已监听 http://${host}:${actualPort}`);
|
|
139
|
+
console.log(`setup token 文件:${join(dataDir, 'setup-token')}(设备 CLI 用)`);
|
|
140
|
+
if (!initialized) {
|
|
141
|
+
console.log('');
|
|
142
|
+
console.log(`首次启动,请打开 http://${host}:${actualPort}/admin 完成初始化`);
|
|
143
|
+
console.log(`初始化密钥:${onboardingCode}`);
|
|
144
|
+
console.log('(10 分钟内有效;未完成初始化时每次启动都会重新生成)');
|
|
145
|
+
} else {
|
|
146
|
+
console.log(`管理台:http://${host}:${actualPort}/admin(用管理密码登录)`);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
const stop = ()=>{
|
|
150
|
+
server.close(()=>{
|
|
151
|
+
db.close();
|
|
152
|
+
process.exit(0);
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
process.once('SIGINT', stop);
|
|
156
|
+
process.once('SIGTERM', stop);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
usage();
|
|
160
|
+
} finally{
|
|
161
|
+
if (parsed.command !== 'serve') db.close();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
void main(parse(process.argv.slice(2))).catch((error)=>{
|
|
165
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
166
|
+
process.exit(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
//# sourceURL=index.ts
|
package/dist/leases.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { SyncError } from './shared/wire.js';
|
|
2
|
+
import { DEFAULT_DATASET, withTransaction } from './db.js';
|
|
3
|
+
export const LEASE_TTL_MS = 60_000;
|
|
4
|
+
export const SYNC_RESOURCE = 'sync';
|
|
5
|
+
export function vaultResource(writer) {
|
|
6
|
+
return `vault-writer:${writer}`;
|
|
7
|
+
}
|
|
8
|
+
export function acquireLease(db, resource, deviceId, clientId, now = Date.now()) {
|
|
9
|
+
if (clientId === '') throw new SyncError('bad-request', '缺少 clientInstanceId');
|
|
10
|
+
return withTransaction(db, ()=>{
|
|
11
|
+
const row = db.handle.prepare('SELECT device_id, client_id, lease_until FROM leases WHERE dataset = ? AND resource = ?').get(DEFAULT_DATASET, resource);
|
|
12
|
+
if (row !== undefined && (row.device_id !== deviceId || row.client_id !== clientId) && row.lease_until > now) {
|
|
13
|
+
throw new SyncError('lease-held', `${resource} 的租约由 ${row.device_id}/${row.client_id} 持有至 ${row.lease_until}`);
|
|
14
|
+
}
|
|
15
|
+
const leaseUntil = now + LEASE_TTL_MS;
|
|
16
|
+
db.handle.prepare(`INSERT INTO leases (dataset, resource, device_id, client_id, lease_until)
|
|
17
|
+
VALUES (?, ?, ?, ?, ?)
|
|
18
|
+
ON CONFLICT(dataset, resource) DO UPDATE SET
|
|
19
|
+
device_id = excluded.device_id, client_id = excluded.client_id, lease_until = excluded.lease_until`).run(DEFAULT_DATASET, resource, deviceId, clientId, leaseUntil);
|
|
20
|
+
return {
|
|
21
|
+
resource,
|
|
22
|
+
deviceId,
|
|
23
|
+
clientId,
|
|
24
|
+
leaseUntil,
|
|
25
|
+
acquired: row === undefined || row.device_id !== deviceId || row.client_id !== clientId || row.lease_until <= now
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export function requireLease(db, resource, deviceId, clientId, now = Date.now()) {
|
|
30
|
+
if (clientId === '') throw new SyncError('bad-request', '缺少 clientInstanceId');
|
|
31
|
+
const row = db.handle.prepare('SELECT device_id, client_id, lease_until FROM leases WHERE dataset = ? AND resource = ?').get(DEFAULT_DATASET, resource);
|
|
32
|
+
if (row === undefined || row.device_id !== deviceId || row.client_id !== clientId || row.lease_until <= now) {
|
|
33
|
+
throw new SyncError('lease-held', `${resource} 的租约未由当前设备/客户端持有或已过期`);
|
|
34
|
+
}
|
|
35
|
+
return row;
|
|
36
|
+
}
|
|
37
|
+
export function releaseLease(db, resource, deviceId, clientId) {
|
|
38
|
+
const result = db.handle.prepare('DELETE FROM leases WHERE dataset = ? AND resource = ? AND device_id = ? AND client_id = ?').run(DEFAULT_DATASET, resource, deviceId, clientId);
|
|
39
|
+
return Number(result.changes) > 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
//# sourceURL=leases.ts
|
package/dist/sessions.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { assertChain, buildManifest, manifestDigest } from './shared/chain.js';
|
|
3
|
+
import { SyncError } from './shared/wire.js';
|
|
4
|
+
import { blobPath } from './blobs.js';
|
|
5
|
+
import { DEFAULT_DATASET, bumpRevision, readRevision, withTransaction } from './db.js';
|
|
6
|
+
import { requireLease as requireResourceLease, SYNC_RESOURCE } from './leases.js';
|
|
7
|
+
export const MAX_APPEND_BODY_BYTES = 8 * 1024 * 1024;
|
|
8
|
+
const HASH = /^[0-9a-f]{64}$/u;
|
|
9
|
+
const KEY_ID = /^[0-9a-f]{16}$/u;
|
|
10
|
+
function assertRef(frame, expectedIndex) {
|
|
11
|
+
if (frame.index !== expectedIndex) throw new SyncError('bad-request', `帧下标应为 ${expectedIndex},实际是 ${frame.index}`);
|
|
12
|
+
if (!HASH.test(frame.chainHash) || !HASH.test(frame.blobSha256)) throw new SyncError('bad-request', `帧 ${expectedIndex} 的哈希非法`);
|
|
13
|
+
if (!KEY_ID.test(frame.keyId)) throw new SyncError('bad-request', `帧 ${expectedIndex} 的 keyId 非法`);
|
|
14
|
+
if (!Number.isSafeInteger(frame.bytes) || frame.bytes <= 0) throw new SyncError('bad-request', `帧 ${expectedIndex} 的字节数非法`);
|
|
15
|
+
if (!Number.isSafeInteger(frame.sealedVersion) || frame.sealedVersion < 1) throw new SyncError('bad-request', `帧 ${expectedIndex} 的封帧版本非法`);
|
|
16
|
+
if (!Number.isSafeInteger(frame.seqFrom) || !Number.isSafeInteger(frame.seqTo)) throw new SyncError('bad-request', `帧 ${expectedIndex} 的 seq 非法`);
|
|
17
|
+
}
|
|
18
|
+
function toChain(frames) {
|
|
19
|
+
return frames.map((frame)=>({
|
|
20
|
+
index: frame.index,
|
|
21
|
+
chainHash: frame.chainHash,
|
|
22
|
+
seqFrom: frame.seqFrom,
|
|
23
|
+
seqTo: frame.seqTo
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
function sessionRow(db, sessionId) {
|
|
27
|
+
return db.handle.prepare('SELECT created_at, format_version, last_seq, frame_count, head_chain_hash, manifest_json, manifest_digest, revision, updated_at, deleted_at FROM sessions WHERE dataset = ? AND session_id = ?').get(DEFAULT_DATASET, sessionId);
|
|
28
|
+
}
|
|
29
|
+
function manifestOf(sessionId, formatVersion, createdAt, updatedAt, frames, tailOpen, safeThroughSeq, workspaceKey) {
|
|
30
|
+
assertChain(toChain(frames));
|
|
31
|
+
const manifest = buildManifest({
|
|
32
|
+
sessionId,
|
|
33
|
+
formatVersion,
|
|
34
|
+
createdAt,
|
|
35
|
+
updatedAt,
|
|
36
|
+
frames,
|
|
37
|
+
...tailOpen === undefined ? {} : {
|
|
38
|
+
tailOpen
|
|
39
|
+
},
|
|
40
|
+
...safeThroughSeq === undefined ? {} : {
|
|
41
|
+
safeThroughSeq
|
|
42
|
+
},
|
|
43
|
+
...workspaceKey === undefined ? {} : {
|
|
44
|
+
workspaceKey
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
manifest,
|
|
49
|
+
digest: manifestDigest(manifest),
|
|
50
|
+
headChainHash: frames[frames.length - 1].chainHash
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function appendFrames(db, dataDir, input, now = Date.now()) {
|
|
54
|
+
return withTransaction(db, ()=>{
|
|
55
|
+
requireResourceLease(db, SYNC_RESOURCE, input.deviceId, input.clientId, now);
|
|
56
|
+
const row = sessionRow(db, input.sessionId);
|
|
57
|
+
let combined;
|
|
58
|
+
let formatVersion = input.formatVersion;
|
|
59
|
+
let createdAt = input.createdAt;
|
|
60
|
+
let workspaceKey = input.workspaceKey;
|
|
61
|
+
if (row !== undefined && row.deleted_at !== null && row.created_at === input.createdAt) {
|
|
62
|
+
throw new SyncError('gone', `会话 ${input.sessionId} 的这个生命周期已被删除`);
|
|
63
|
+
}
|
|
64
|
+
const replacingLifecycle = row !== undefined && row.deleted_at !== null && row.created_at !== input.createdAt;
|
|
65
|
+
if (row !== undefined && row.deleted_at === null && row.created_at !== input.createdAt) {
|
|
66
|
+
throw new SyncError('conflict', `会话 ${input.sessionId} 已存在另一个生命周期(createdAt=${row.created_at})`);
|
|
67
|
+
}
|
|
68
|
+
if (row === undefined || replacingLifecycle) {
|
|
69
|
+
if (input.baseIndex !== -1 || input.baseChainHash !== null) throw new SyncError('conflict', '新会话必须从 baseIndex=-1 开始');
|
|
70
|
+
if (input.frames.length === 0) throw new SyncError('bad-request', 'append 至少需要一帧');
|
|
71
|
+
combined = [
|
|
72
|
+
...input.frames
|
|
73
|
+
];
|
|
74
|
+
} else {
|
|
75
|
+
const existing = JSON.parse(row.manifest_json);
|
|
76
|
+
formatVersion = existing.formatVersion;
|
|
77
|
+
createdAt = existing.createdAt;
|
|
78
|
+
if (workspaceKey !== undefined && existing.workspaceKey !== undefined && workspaceKey !== existing.workspaceKey) {
|
|
79
|
+
throw new SyncError('conflict', 'append 的 workspaceKey 与已存 manifest 不一致');
|
|
80
|
+
}
|
|
81
|
+
if (workspaceKey === undefined && existing.workspaceKey !== undefined) workspaceKey = existing.workspaceKey;
|
|
82
|
+
if (workspaceKey !== undefined && existing.workspaceKey === undefined) {
|
|
83
|
+
throw new SyncError('conflict', '已有会话缺少 workspaceKey,不能在后续 append 补写');
|
|
84
|
+
}
|
|
85
|
+
const start = input.baseIndex + 1;
|
|
86
|
+
if (start < 0 || start > existing.frames.length) throw new SyncError('diverged', `baseIndex=${input.baseIndex} 超出链长 ${existing.frames.length}`);
|
|
87
|
+
if (start < existing.frames.length - 1) throw new SyncError('diverged', '只能追加或替换尾帧');
|
|
88
|
+
if (input.baseIndex >= 0) {
|
|
89
|
+
const base = existing.frames[input.baseIndex];
|
|
90
|
+
if (base === undefined || base.chainHash !== input.baseChainHash) throw new SyncError('diverged', 'baseChainHash 与当前链不匹配');
|
|
91
|
+
} else if (existing.frames.length !== 0) {
|
|
92
|
+
throw new SyncError('diverged', 'baseIndex=-1 但会话已存在');
|
|
93
|
+
}
|
|
94
|
+
if (input.frames.length === 0) throw new SyncError('bad-request', 'append 至少需要一帧');
|
|
95
|
+
combined = [
|
|
96
|
+
...existing.frames.slice(0, start),
|
|
97
|
+
...input.frames
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
for (const frame of input.frames){
|
|
101
|
+
if (!existsSync(blobPath(dataDir, frame.blobSha256))) throw new SyncError('bad-request', `帧 ${frame.index} 的 blob 尚未上传`);
|
|
102
|
+
}
|
|
103
|
+
input.frames.forEach((frame, offset)=>assertRef(frame, (row === undefined || replacingLifecycle ? 0 : input.baseIndex + 1) + offset));
|
|
104
|
+
const { manifest, digest, headChainHash } = manifestOf(input.sessionId, formatVersion, createdAt, now, combined, input.tailOpen, input.safeThroughSeq, workspaceKey);
|
|
105
|
+
const revision = bumpRevision(db);
|
|
106
|
+
db.handle.prepare(`INSERT INTO sessions (dataset, session_id, created_at, format_version, last_seq, frame_count, head_chain_hash, manifest_json, manifest_digest, revision, updated_at, deleted_at)
|
|
107
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
|
108
|
+
ON CONFLICT(dataset, session_id) DO UPDATE SET
|
|
109
|
+
created_at = excluded.created_at, format_version = excluded.format_version, last_seq = excluded.last_seq,
|
|
110
|
+
frame_count = excluded.frame_count, head_chain_hash = excluded.head_chain_hash,
|
|
111
|
+
manifest_json = excluded.manifest_json, manifest_digest = excluded.manifest_digest,
|
|
112
|
+
revision = excluded.revision, updated_at = excluded.updated_at, deleted_at = NULL`).run(DEFAULT_DATASET, input.sessionId, createdAt, formatVersion, manifest.lastSeq, combined.length, headChainHash, JSON.stringify(manifest), digest, revision, now);
|
|
113
|
+
return {
|
|
114
|
+
sessionId: input.sessionId,
|
|
115
|
+
revision,
|
|
116
|
+
batchCount: combined.length,
|
|
117
|
+
headChainHash,
|
|
118
|
+
manifestDigest: digest,
|
|
119
|
+
lastSeq: manifest.lastSeq,
|
|
120
|
+
updatedAt: now,
|
|
121
|
+
deletedAt: null
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
export function deleteSession(db, sessionId, deviceId, clientId, createdAt, now = Date.now()) {
|
|
126
|
+
return withTransaction(db, ()=>{
|
|
127
|
+
requireResourceLease(db, SYNC_RESOURCE, deviceId, clientId, now);
|
|
128
|
+
const row = sessionRow(db, sessionId);
|
|
129
|
+
if (row === undefined) throw new SyncError('not-found', `没有会话 ${sessionId}`);
|
|
130
|
+
if (row.created_at !== createdAt) throw new SyncError('conflict', 'createdAt 与当前生命周期不符');
|
|
131
|
+
if (row.deleted_at !== null) throw new SyncError('gone', `会话 ${sessionId} 已删除`);
|
|
132
|
+
const revision = bumpRevision(db);
|
|
133
|
+
db.handle.prepare('UPDATE sessions SET deleted_at = ?, revision = ?, updated_at = ? WHERE dataset = ? AND session_id = ?').run(now, revision, now, DEFAULT_DATASET, sessionId);
|
|
134
|
+
db.handle.prepare('INSERT OR REPLACE INTO tombstones (dataset, session_id, created_at, deleted_at, deleted_by) VALUES (?, ?, ?, ?, ?)').run(DEFAULT_DATASET, sessionId, createdAt, now, deviceId);
|
|
135
|
+
return {
|
|
136
|
+
sessionId,
|
|
137
|
+
revision,
|
|
138
|
+
batchCount: row.frame_count,
|
|
139
|
+
headChainHash: row.head_chain_hash,
|
|
140
|
+
manifestDigest: row.manifest_digest,
|
|
141
|
+
lastSeq: row.last_seq,
|
|
142
|
+
updatedAt: now,
|
|
143
|
+
deletedAt: now
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
export function getManifest(db, sessionId) {
|
|
148
|
+
const row = sessionRow(db, sessionId);
|
|
149
|
+
if (row === undefined) return undefined;
|
|
150
|
+
return {
|
|
151
|
+
manifest: JSON.parse(row.manifest_json),
|
|
152
|
+
revision: row.revision
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
export function listBatches(db, sessionId, from, limit) {
|
|
156
|
+
const row = sessionRow(db, sessionId);
|
|
157
|
+
if (row === undefined) return undefined;
|
|
158
|
+
const manifest = JSON.parse(row.manifest_json);
|
|
159
|
+
return {
|
|
160
|
+
batches: manifest.frames.slice(from, from + limit),
|
|
161
|
+
batchCount: manifest.frames.length,
|
|
162
|
+
revision: row.revision
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
export function adminSessions(db, limit = 500) {
|
|
166
|
+
const rows = db.handle.prepare(`SELECT s.session_id, s.created_at, s.format_version, s.frame_count, s.last_seq, s.head_chain_hash,
|
|
167
|
+
s.revision, s.updated_at, s.deleted_at,
|
|
168
|
+
json_extract(s.manifest_json, '$.workspaceKey') AS workspace_key
|
|
169
|
+
FROM sessions s
|
|
170
|
+
WHERE s.dataset = ? ORDER BY s.updated_at DESC LIMIT ?`).all(DEFAULT_DATASET, limit);
|
|
171
|
+
return rows.map((row)=>({
|
|
172
|
+
sessionId: row.session_id,
|
|
173
|
+
createdAt: row.created_at,
|
|
174
|
+
formatVersion: row.format_version,
|
|
175
|
+
batchCount: row.frame_count,
|
|
176
|
+
lastSeq: row.last_seq,
|
|
177
|
+
headChainHash: row.head_chain_hash,
|
|
178
|
+
revision: row.revision,
|
|
179
|
+
updatedAt: row.updated_at,
|
|
180
|
+
deletedAt: row.deleted_at,
|
|
181
|
+
workspaceKey: row.workspace_key === null ? null : String(row.workspace_key)
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
export function sessionCounts(db) {
|
|
185
|
+
const row = db.handle.prepare('SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN deleted_at IS NULL THEN 0 ELSE 1 END), 0) AS deleted FROM sessions WHERE dataset = ?').get(DEFAULT_DATASET);
|
|
186
|
+
return {
|
|
187
|
+
total: Number(row?.total ?? 0),
|
|
188
|
+
deleted: Number(row?.deleted ?? 0)
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
export function listChanges(db, since, limit) {
|
|
192
|
+
const rows = db.handle.prepare('SELECT session_id, created_at, format_version, frame_count, head_chain_hash, manifest_digest, revision, updated_at, deleted_at FROM sessions WHERE dataset = ? AND revision > ? ORDER BY revision ASC LIMIT ?').all(DEFAULT_DATASET, since, limit + 1);
|
|
193
|
+
const tombstones = db.handle.prepare('SELECT session_id, created_at, deleted_at FROM tombstones WHERE dataset = ? AND deleted_at > ? ORDER BY deleted_at ASC LIMIT ?').all(DEFAULT_DATASET, since, limit + 1);
|
|
194
|
+
const sessions = rows.slice(0, limit).map((row)=>({
|
|
195
|
+
sessionId: row.session_id,
|
|
196
|
+
revision: row.revision,
|
|
197
|
+
formatVersion: row.format_version,
|
|
198
|
+
createdAt: row.created_at,
|
|
199
|
+
batchCount: row.frame_count,
|
|
200
|
+
headChainHash: row.head_chain_hash,
|
|
201
|
+
manifestDigest: row.manifest_digest,
|
|
202
|
+
updatedAt: row.updated_at,
|
|
203
|
+
deletedAt: row.deleted_at
|
|
204
|
+
}));
|
|
205
|
+
return {
|
|
206
|
+
revision: readRevision(db),
|
|
207
|
+
sessions,
|
|
208
|
+
tombstones: tombstones.slice(0, limit).map((row)=>({
|
|
209
|
+
sessionId: row.session_id,
|
|
210
|
+
createdAt: row.created_at,
|
|
211
|
+
deletedAt: row.deleted_at
|
|
212
|
+
})),
|
|
213
|
+
hasMore: rows.length > limit || tombstones.length > limit
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
//# sourceURL=sessions.ts
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { sha256 } from './crypto.js';
|
|
2
|
+
import { stableStringify } from './chain.js';
|
|
3
|
+
import { openSealedFrame, sealFrame } from './sealed-frame.js';
|
|
4
|
+
export const HEADER_INDEX = 0;
|
|
5
|
+
export const HEADER_SEQ = -1;
|
|
6
|
+
export const DEFAULT_BATCH_EVENTS = 50;
|
|
7
|
+
export const DEFAULT_BATCH_BYTES = 256 * 1024;
|
|
8
|
+
export function headerMetaOf(header, inheritedEventCount) {
|
|
9
|
+
return {
|
|
10
|
+
formatVersion: header.version,
|
|
11
|
+
createdAt: header.createdAt,
|
|
12
|
+
isSeeded: header.isSeeded,
|
|
13
|
+
delegationDepth: header.delegationDepth ?? 0,
|
|
14
|
+
...header.agentPreset === undefined ? {} : {
|
|
15
|
+
agentPreset: header.agentPreset
|
|
16
|
+
},
|
|
17
|
+
...header.parentSession === undefined ? {} : {
|
|
18
|
+
parentSession: header.parentSession
|
|
19
|
+
},
|
|
20
|
+
...header.origin === undefined ? {} : {
|
|
21
|
+
origin: header.origin
|
|
22
|
+
},
|
|
23
|
+
...header.isSeeded ? {
|
|
24
|
+
inheritedEventCount: inheritedEventCount ?? 0
|
|
25
|
+
} : {}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function headerChainHash(meta) {
|
|
29
|
+
return sha256(stableStringify(meta));
|
|
30
|
+
}
|
|
31
|
+
export function sealHeader(meta, sessionId, sessionKey) {
|
|
32
|
+
const plain = Buffer.from(stableStringify(meta), 'utf8');
|
|
33
|
+
return sealFrame(plain, {
|
|
34
|
+
sessionId,
|
|
35
|
+
index: HEADER_INDEX,
|
|
36
|
+
seqFrom: HEADER_SEQ,
|
|
37
|
+
seqTo: HEADER_SEQ
|
|
38
|
+
}, sessionKey);
|
|
39
|
+
}
|
|
40
|
+
export function openHeader(sealed, ref, sessionId, sessionKey) {
|
|
41
|
+
if (ref.index !== HEADER_INDEX || ref.seqFrom !== HEADER_SEQ || ref.seqTo !== HEADER_SEQ) {
|
|
42
|
+
throw new Error('第 0 个链条目不是会话头');
|
|
43
|
+
}
|
|
44
|
+
const plain = openSealedFrame(sealed, {
|
|
45
|
+
sessionId,
|
|
46
|
+
index: ref.index,
|
|
47
|
+
seqFrom: ref.seqFrom,
|
|
48
|
+
seqTo: ref.seqTo,
|
|
49
|
+
chainHash: ref.chainHash
|
|
50
|
+
}, sessionKey);
|
|
51
|
+
return parseHeaderMeta(JSON.parse(plain.toString('utf8')));
|
|
52
|
+
}
|
|
53
|
+
function parseEvents(value) {
|
|
54
|
+
if (!Array.isArray(value)) throw new Error('批次不是事件数组');
|
|
55
|
+
return value.map((entry, position)=>{
|
|
56
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) throw new Error(`批次第 ${position} 项不是事件对象`);
|
|
57
|
+
const seq = entry['seq'];
|
|
58
|
+
if (!Number.isSafeInteger(seq) || seq < 0) throw new Error(`批次第 ${position} 项的 seq 非法`);
|
|
59
|
+
return entry;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function parseHeaderMeta(value) {
|
|
63
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('会话头不是对象');
|
|
64
|
+
const record = value;
|
|
65
|
+
const formatVersion = record['formatVersion'];
|
|
66
|
+
const createdAt = record['createdAt'];
|
|
67
|
+
const isSeeded = record['isSeeded'];
|
|
68
|
+
const delegationDepth = record['delegationDepth'];
|
|
69
|
+
if (!Number.isSafeInteger(formatVersion) || formatVersion < 1) throw new Error('会话头 formatVersion 非法');
|
|
70
|
+
if (!Number.isSafeInteger(createdAt)) throw new Error('会话头 createdAt 非法');
|
|
71
|
+
if (typeof isSeeded !== 'boolean') throw new Error('会话头 isSeeded 非法');
|
|
72
|
+
if (!Number.isSafeInteger(delegationDepth) || delegationDepth < 0) throw new Error('会话头 delegationDepth 非法');
|
|
73
|
+
const stringField = (key)=>{
|
|
74
|
+
const field = record[key];
|
|
75
|
+
if (field === undefined) return undefined;
|
|
76
|
+
if (typeof field !== 'string') throw new Error(`会话头 ${key} 非法`);
|
|
77
|
+
return field;
|
|
78
|
+
};
|
|
79
|
+
const inherited = record['inheritedEventCount'];
|
|
80
|
+
if (inherited !== undefined && (!Number.isSafeInteger(inherited) || inherited < 0)) {
|
|
81
|
+
throw new Error('会话头 inheritedEventCount 非法');
|
|
82
|
+
}
|
|
83
|
+
const agentPreset = stringField('agentPreset');
|
|
84
|
+
const parentSession = stringField('parentSession');
|
|
85
|
+
const origin = stringField('origin');
|
|
86
|
+
return {
|
|
87
|
+
formatVersion: formatVersion,
|
|
88
|
+
createdAt: createdAt,
|
|
89
|
+
isSeeded,
|
|
90
|
+
delegationDepth: delegationDepth,
|
|
91
|
+
...agentPreset === undefined ? {} : {
|
|
92
|
+
agentPreset
|
|
93
|
+
},
|
|
94
|
+
...parentSession === undefined ? {} : {
|
|
95
|
+
parentSession
|
|
96
|
+
},
|
|
97
|
+
...origin === undefined ? {} : {
|
|
98
|
+
origin
|
|
99
|
+
},
|
|
100
|
+
...inherited === undefined ? {} : {
|
|
101
|
+
inheritedEventCount: inherited
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
export function serializeEventBatch(events) {
|
|
106
|
+
return stableStringify(events);
|
|
107
|
+
}
|
|
108
|
+
export function eventBatchHash(events) {
|
|
109
|
+
return sha256(serializeEventBatch(events));
|
|
110
|
+
}
|
|
111
|
+
export function sealEventBatchFromText(text, sessionId, index, seqFrom, seqTo, sessionKey) {
|
|
112
|
+
if (index < 1) throw new Error('事件批次的链下标必须 >= 1');
|
|
113
|
+
if (seqFrom > seqTo) throw new Error('事件批次的 seq 区间非法');
|
|
114
|
+
return sealFrame(Buffer.from(text, 'utf8'), {
|
|
115
|
+
sessionId,
|
|
116
|
+
index,
|
|
117
|
+
seqFrom,
|
|
118
|
+
seqTo
|
|
119
|
+
}, sessionKey);
|
|
120
|
+
}
|
|
121
|
+
export function sealEventBatch(events, sessionId, index, sessionKey) {
|
|
122
|
+
if (index < 1) throw new Error('事件批次的链下标必须 >= 1');
|
|
123
|
+
if (events.length === 0) throw new Error('不能封空批次');
|
|
124
|
+
const seqFrom = events[0].seq;
|
|
125
|
+
const seqTo = events[events.length - 1].seq;
|
|
126
|
+
assertContiguous(events, seqFrom, seqTo);
|
|
127
|
+
return sealEventBatchFromText(serializeEventBatch(events), sessionId, index, seqFrom, seqTo, sessionKey);
|
|
128
|
+
}
|
|
129
|
+
export function openEventBatch(sealed, ref, sessionId, sessionKey) {
|
|
130
|
+
const plain = openSealedFrame(sealed, {
|
|
131
|
+
sessionId,
|
|
132
|
+
index: ref.index,
|
|
133
|
+
seqFrom: ref.seqFrom,
|
|
134
|
+
seqTo: ref.seqTo,
|
|
135
|
+
chainHash: ref.chainHash
|
|
136
|
+
}, sessionKey);
|
|
137
|
+
const events = parseEvents(JSON.parse(plain.toString('utf8')));
|
|
138
|
+
if (events.length !== ref.seqTo - ref.seqFrom + 1) throw new Error('批次事件数与 seq 区间不符');
|
|
139
|
+
if (events.length === 0 || events[0].seq !== ref.seqFrom || events[events.length - 1].seq !== ref.seqTo) {
|
|
140
|
+
throw new Error('批次首尾 seq 与清单不符');
|
|
141
|
+
}
|
|
142
|
+
assertContiguous(events, ref.seqFrom, ref.seqTo);
|
|
143
|
+
return events;
|
|
144
|
+
}
|
|
145
|
+
function assertContiguous(events, seqFrom, seqTo) {
|
|
146
|
+
if (events.length !== seqTo - seqFrom + 1) throw new Error('事件数与 seq 区间不符');
|
|
147
|
+
events.forEach((event, offset)=>{
|
|
148
|
+
if (event.seq !== seqFrom + offset) throw new Error(`第 ${offset} 个事件的 seq 应为 ${seqFrom + offset},实际是 ${event.seq}`);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
export function* iterateEventBatches(events, startAfterSeq, options = {}, includeText = true) {
|
|
152
|
+
const maxEvents = options.maxEvents ?? DEFAULT_BATCH_EVENTS;
|
|
153
|
+
const maxBytes = options.maxBytes ?? DEFAULT_BATCH_BYTES;
|
|
154
|
+
if (!Number.isSafeInteger(maxEvents) || maxEvents < 1) throw new Error('maxEvents 必须是正整数');
|
|
155
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error('maxBytes 必须是正整数');
|
|
156
|
+
events.forEach((event, offset)=>{
|
|
157
|
+
if (!Number.isSafeInteger(event.seq) || event.seq < 0) throw new Error(`第 ${offset} 个事件的 seq 非法`);
|
|
158
|
+
if (offset > 0 && events[offset - 1].seq + 1 !== event.seq) throw new Error(`事件 seq 不连续:${events[offset - 1].seq} -> ${event.seq}`);
|
|
159
|
+
});
|
|
160
|
+
const pending = events.filter((event)=>event.seq > startAfterSeq);
|
|
161
|
+
if (pending.length === 0) return;
|
|
162
|
+
if (pending[0].seq !== startAfterSeq + 1) throw new Error(`待传事件应从 ${startAfterSeq + 1} 开始,实际是 ${pending[0].seq}`);
|
|
163
|
+
let current = [];
|
|
164
|
+
let currentTexts = includeText ? [] : undefined;
|
|
165
|
+
let currentBytes = 2;
|
|
166
|
+
for (const event of pending){
|
|
167
|
+
const eventText = stableStringify(event);
|
|
168
|
+
const eventBytes = Buffer.byteLength(eventText, 'utf8');
|
|
169
|
+
const candidateBytes = current.length === 0 ? currentBytes + eventBytes : currentBytes + 1 + eventBytes;
|
|
170
|
+
const candidateLength = current.length + 1;
|
|
171
|
+
if (current.length > 0 && (candidateLength > maxEvents || candidateBytes > maxBytes)) {
|
|
172
|
+
yield {
|
|
173
|
+
seqFrom: current[0].seq,
|
|
174
|
+
seqTo: current[current.length - 1].seq,
|
|
175
|
+
events: current,
|
|
176
|
+
bytes: currentBytes,
|
|
177
|
+
text: currentTexts === undefined ? '' : `[${currentTexts.join(',')}]`
|
|
178
|
+
};
|
|
179
|
+
current = [
|
|
180
|
+
event
|
|
181
|
+
];
|
|
182
|
+
currentTexts = includeText ? [
|
|
183
|
+
eventText
|
|
184
|
+
] : undefined;
|
|
185
|
+
currentBytes = 2 + eventBytes;
|
|
186
|
+
} else {
|
|
187
|
+
current.push(event);
|
|
188
|
+
currentTexts?.push(eventText);
|
|
189
|
+
currentBytes = candidateBytes;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (current.length > 0) {
|
|
193
|
+
yield {
|
|
194
|
+
seqFrom: current[0].seq,
|
|
195
|
+
seqTo: current[current.length - 1].seq,
|
|
196
|
+
events: current,
|
|
197
|
+
bytes: currentBytes,
|
|
198
|
+
text: currentTexts === undefined ? '' : `[${currentTexts.join(',')}]`
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
export function planEventBatches(events, startAfterSeq, options = {}) {
|
|
203
|
+
return [
|
|
204
|
+
...iterateEventBatches(events, startAfterSeq, options, false)
|
|
205
|
+
].map(({ seqFrom, seqTo, events: batchEvents, bytes })=>({
|
|
206
|
+
seqFrom,
|
|
207
|
+
seqTo,
|
|
208
|
+
events: batchEvents,
|
|
209
|
+
bytes
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
//# sourceURL=batches.ts
|