dscs-server 0.0.0-stage → 0.1.0
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 +39 -2
- package/dist/blobs.js +50 -0
- package/dist/db.js +333 -0
- package/dist/http.js +1281 -0
- package/dist/index.js +170 -0
- package/dist/leases.js +43 -0
- package/dist/sessions.js +369 -0
- package/dist/shared/batches.js +230 -0
- package/dist/shared/blob-batch.js +85 -0
- package/dist/shared/chain.js +152 -0
- package/dist/shared/crypto.js +70 -0
- package/dist/shared/frames.js +102 -0
- package/dist/shared/index.js +12 -0
- package/dist/shared/protocol.js +14 -0
- package/dist/shared/sealed-frame.js +155 -0
- package/dist/shared/vault.js +210 -0
- package/dist/shared/wire.js +25 -0
- package/dist/vault.js +147 -0
- package/package.json +43 -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,369 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { assertChain, buildManifest, manifestDigest, stableStringify } from './shared/chain.js';
|
|
3
|
+
import { sha256 } from './shared/crypto.js';
|
|
4
|
+
import { SyncError } from './shared/wire.js';
|
|
5
|
+
import { blobPath } from './blobs.js';
|
|
6
|
+
import { DEFAULT_DATASET, bumpRevision, readAppendRequest, readRevision, withTransaction, writeAppendRequest } from './db.js';
|
|
7
|
+
import { requireLease as requireResourceLease, SYNC_RESOURCE } from './leases.js';
|
|
8
|
+
export const MAX_APPEND_BODY_BYTES = 8 * 1024 * 1024;
|
|
9
|
+
const HASH = /^[0-9a-f]{64}$/u;
|
|
10
|
+
const KEY_ID = /^[0-9a-f]{16}$/u;
|
|
11
|
+
function assertRef(frame, expectedIndex) {
|
|
12
|
+
if (frame.index !== expectedIndex) throw new SyncError('bad-request', `帧下标应为 ${expectedIndex},实际是 ${frame.index}`);
|
|
13
|
+
if (!HASH.test(frame.chainHash) || !HASH.test(frame.blobSha256)) throw new SyncError('bad-request', `帧 ${expectedIndex} 的哈希非法`);
|
|
14
|
+
if (!KEY_ID.test(frame.keyId)) throw new SyncError('bad-request', `帧 ${expectedIndex} 的 keyId 非法`);
|
|
15
|
+
if (!Number.isSafeInteger(frame.bytes) || frame.bytes <= 0) throw new SyncError('bad-request', `帧 ${expectedIndex} 的字节数非法`);
|
|
16
|
+
if (!Number.isSafeInteger(frame.sealedVersion) || frame.sealedVersion < 1) throw new SyncError('bad-request', `帧 ${expectedIndex} 的封帧版本非法`);
|
|
17
|
+
if (frame.compression === undefined) {
|
|
18
|
+
if (frame.sealedVersion !== 1 || frame.compressedHash !== undefined) {
|
|
19
|
+
throw new SyncError('bad-request', `帧 ${expectedIndex} 缺少压缩元数据`);
|
|
20
|
+
}
|
|
21
|
+
} else {
|
|
22
|
+
if (frame.compression !== 'zstd' || frame.sealedVersion < 2) {
|
|
23
|
+
throw new SyncError('bad-request', `帧 ${expectedIndex} 的压缩元数据非法`);
|
|
24
|
+
}
|
|
25
|
+
if (typeof frame.compressedHash !== 'string' || !HASH.test(frame.compressedHash)) {
|
|
26
|
+
throw new SyncError('bad-request', `帧 ${expectedIndex} 的 compressedHash 非法`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (!Number.isSafeInteger(frame.seqFrom) || !Number.isSafeInteger(frame.seqTo)) throw new SyncError('bad-request', `帧 ${expectedIndex} 的 seq 非法`);
|
|
30
|
+
}
|
|
31
|
+
function toChain(frames) {
|
|
32
|
+
return frames.map((frame)=>({
|
|
33
|
+
index: frame.index,
|
|
34
|
+
chainHash: frame.chainHash,
|
|
35
|
+
seqFrom: frame.seqFrom,
|
|
36
|
+
seqTo: frame.seqTo
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
function sessionRow(db, sessionId) {
|
|
40
|
+
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, compression, stored_bytes FROM sessions WHERE dataset = ? AND session_id = ?').get(DEFAULT_DATASET, sessionId);
|
|
41
|
+
}
|
|
42
|
+
function manifestOf(sessionId, formatVersion, createdAt, updatedAt, frames, tailOpen, safeThroughSeq, workspaceKey) {
|
|
43
|
+
assertChain(toChain(frames));
|
|
44
|
+
const manifest = buildManifest({
|
|
45
|
+
sessionId,
|
|
46
|
+
formatVersion,
|
|
47
|
+
createdAt,
|
|
48
|
+
updatedAt,
|
|
49
|
+
frames,
|
|
50
|
+
...tailOpen === undefined ? {} : {
|
|
51
|
+
tailOpen
|
|
52
|
+
},
|
|
53
|
+
...safeThroughSeq === undefined ? {} : {
|
|
54
|
+
safeThroughSeq
|
|
55
|
+
},
|
|
56
|
+
...workspaceKey === undefined ? {} : {
|
|
57
|
+
workspaceKey
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
manifest,
|
|
62
|
+
digest: manifestDigest(manifest),
|
|
63
|
+
headChainHash: frames[frames.length - 1].chainHash
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function compressionOf(value) {
|
|
67
|
+
return value === 'zstd' || value === 'mixed' ? value : 'none';
|
|
68
|
+
}
|
|
69
|
+
function statsOf(frames) {
|
|
70
|
+
let compressed = 0;
|
|
71
|
+
let storedBytes = 0;
|
|
72
|
+
for (const frame of frames){
|
|
73
|
+
if (frame.compression === 'zstd') compressed += 1;
|
|
74
|
+
storedBytes += frame.bytes;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
compression: compressed === 0 ? 'none' : compressed === frames.length ? 'zstd' : 'mixed',
|
|
78
|
+
storedBytes
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function appendRequestHash(input) {
|
|
82
|
+
return sha256(stableStringify({
|
|
83
|
+
sessionId: input.sessionId,
|
|
84
|
+
baseIndex: input.baseIndex,
|
|
85
|
+
baseChainHash: input.baseChainHash,
|
|
86
|
+
createdAt: input.createdAt,
|
|
87
|
+
formatVersion: input.formatVersion,
|
|
88
|
+
frames: input.frames,
|
|
89
|
+
tailOpen: input.tailOpen ?? null,
|
|
90
|
+
safeThroughSeq: input.safeThroughSeq ?? null,
|
|
91
|
+
workspaceKey: input.workspaceKey ?? null
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
export function appendFrames(db, dataDir, input, now = Date.now()) {
|
|
95
|
+
return withTransaction(db, ()=>{
|
|
96
|
+
const idempotencyKey = input.idempotencyKey;
|
|
97
|
+
const requestHash = idempotencyKey === undefined ? undefined : appendRequestHash(input);
|
|
98
|
+
if (idempotencyKey !== undefined && requestHash !== undefined) {
|
|
99
|
+
const existing = readAppendRequest(db, input.deviceId, input.sessionId, idempotencyKey);
|
|
100
|
+
if (existing !== undefined) {
|
|
101
|
+
if (existing.requestHash !== requestHash) {
|
|
102
|
+
throw new SyncError('conflict', 'Idempotency-Key 已用于不同的 append 请求');
|
|
103
|
+
}
|
|
104
|
+
return JSON.parse(existing.resultJson);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
requireResourceLease(db, SYNC_RESOURCE, input.deviceId, input.clientId, now);
|
|
108
|
+
const row = sessionRow(db, input.sessionId);
|
|
109
|
+
let combined;
|
|
110
|
+
let formatVersion = input.formatVersion;
|
|
111
|
+
let createdAt = input.createdAt;
|
|
112
|
+
let workspaceKey = input.workspaceKey;
|
|
113
|
+
if (row !== undefined && row.deleted_at !== null && row.created_at === input.createdAt) {
|
|
114
|
+
throw new SyncError('gone', `会话 ${input.sessionId} 的这个生命周期已被删除`);
|
|
115
|
+
}
|
|
116
|
+
const replacingLifecycle = row !== undefined && row.deleted_at !== null && row.created_at !== input.createdAt;
|
|
117
|
+
if (row !== undefined && row.deleted_at === null && row.created_at !== input.createdAt) {
|
|
118
|
+
throw new SyncError('conflict', `会话 ${input.sessionId} 已存在另一个生命周期(createdAt=${row.created_at})`);
|
|
119
|
+
}
|
|
120
|
+
if (row === undefined || replacingLifecycle) {
|
|
121
|
+
if (input.baseIndex !== -1 || input.baseChainHash !== null) throw new SyncError('conflict', '新会话必须从 baseIndex=-1 开始');
|
|
122
|
+
if (input.frames.length === 0) throw new SyncError('bad-request', 'append 至少需要一帧');
|
|
123
|
+
combined = [
|
|
124
|
+
...input.frames
|
|
125
|
+
];
|
|
126
|
+
} else {
|
|
127
|
+
const existing = JSON.parse(row.manifest_json);
|
|
128
|
+
formatVersion = existing.formatVersion;
|
|
129
|
+
createdAt = existing.createdAt;
|
|
130
|
+
if (workspaceKey !== undefined && existing.workspaceKey !== undefined && workspaceKey !== existing.workspaceKey) {
|
|
131
|
+
throw new SyncError('conflict', 'append 的 workspaceKey 与已存 manifest 不一致');
|
|
132
|
+
}
|
|
133
|
+
if (workspaceKey === undefined && existing.workspaceKey !== undefined) workspaceKey = existing.workspaceKey;
|
|
134
|
+
if (workspaceKey !== undefined && existing.workspaceKey === undefined) {
|
|
135
|
+
throw new SyncError('conflict', '已有会话缺少 workspaceKey,不能在后续 append 补写');
|
|
136
|
+
}
|
|
137
|
+
const start = input.baseIndex + 1;
|
|
138
|
+
if (start < 0 || start > existing.frames.length) throw new SyncError('diverged', `baseIndex=${input.baseIndex} 超出链长 ${existing.frames.length}`);
|
|
139
|
+
if (start < existing.frames.length - 1) throw new SyncError('diverged', '只能追加或替换尾帧');
|
|
140
|
+
if (input.baseIndex >= 0) {
|
|
141
|
+
const base = existing.frames[input.baseIndex];
|
|
142
|
+
if (base === undefined || base.chainHash !== input.baseChainHash) throw new SyncError('diverged', 'baseChainHash 与当前链不匹配');
|
|
143
|
+
} else if (existing.frames.length !== 0) {
|
|
144
|
+
throw new SyncError('diverged', 'baseIndex=-1 但会话已存在');
|
|
145
|
+
}
|
|
146
|
+
if (input.frames.length === 0) throw new SyncError('bad-request', 'append 至少需要一帧');
|
|
147
|
+
combined = [
|
|
148
|
+
...existing.frames.slice(0, start),
|
|
149
|
+
...input.frames
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
for (const frame of input.frames){
|
|
153
|
+
if (!existsSync(blobPath(dataDir, frame.blobSha256))) throw new SyncError('bad-request', `帧 ${frame.index} 的 blob 尚未上传`);
|
|
154
|
+
}
|
|
155
|
+
input.frames.forEach((frame, offset)=>assertRef(frame, (row === undefined || replacingLifecycle ? 0 : input.baseIndex + 1) + offset));
|
|
156
|
+
const { manifest, digest, headChainHash } = manifestOf(input.sessionId, formatVersion, createdAt, now, combined, input.tailOpen, input.safeThroughSeq, workspaceKey);
|
|
157
|
+
const revision = bumpRevision(db);
|
|
158
|
+
const stats = statsOf(combined);
|
|
159
|
+
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, compression, stored_bytes)
|
|
160
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
|
|
161
|
+
ON CONFLICT(dataset, session_id) DO UPDATE SET
|
|
162
|
+
created_at = excluded.created_at, format_version = excluded.format_version, last_seq = excluded.last_seq,
|
|
163
|
+
frame_count = excluded.frame_count, head_chain_hash = excluded.head_chain_hash,
|
|
164
|
+
manifest_json = excluded.manifest_json, manifest_digest = excluded.manifest_digest,
|
|
165
|
+
revision = excluded.revision, updated_at = excluded.updated_at, deleted_at = NULL,
|
|
166
|
+
compression = excluded.compression, stored_bytes = excluded.stored_bytes`).run(DEFAULT_DATASET, input.sessionId, createdAt, formatVersion, manifest.lastSeq, combined.length, headChainHash, JSON.stringify(manifest), digest, revision, now, stats.compression, stats.storedBytes);
|
|
167
|
+
const summary = {
|
|
168
|
+
sessionId: input.sessionId,
|
|
169
|
+
revision,
|
|
170
|
+
batchCount: combined.length,
|
|
171
|
+
headChainHash,
|
|
172
|
+
manifestDigest: digest,
|
|
173
|
+
lastSeq: manifest.lastSeq,
|
|
174
|
+
updatedAt: now,
|
|
175
|
+
deletedAt: null,
|
|
176
|
+
compression: stats.compression,
|
|
177
|
+
storedBytes: stats.storedBytes
|
|
178
|
+
};
|
|
179
|
+
if (idempotencyKey !== undefined && requestHash !== undefined) {
|
|
180
|
+
writeAppendRequest(db, input.deviceId, input.sessionId, idempotencyKey, requestHash, JSON.stringify(summary), now);
|
|
181
|
+
}
|
|
182
|
+
return summary;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
export function deleteSession(db, sessionId, deviceId, clientId, createdAt, now = Date.now()) {
|
|
186
|
+
return withTransaction(db, ()=>{
|
|
187
|
+
requireResourceLease(db, SYNC_RESOURCE, deviceId, clientId, now);
|
|
188
|
+
const row = sessionRow(db, sessionId);
|
|
189
|
+
if (row === undefined) throw new SyncError('not-found', `没有会话 ${sessionId}`);
|
|
190
|
+
if (row.created_at !== createdAt) throw new SyncError('conflict', 'createdAt 与当前生命周期不符');
|
|
191
|
+
if (row.deleted_at !== null) throw new SyncError('gone', `会话 ${sessionId} 已删除`);
|
|
192
|
+
const revision = bumpRevision(db);
|
|
193
|
+
db.handle.prepare('UPDATE sessions SET deleted_at = ?, revision = ?, updated_at = ? WHERE dataset = ? AND session_id = ?').run(now, revision, now, DEFAULT_DATASET, sessionId);
|
|
194
|
+
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);
|
|
195
|
+
return {
|
|
196
|
+
sessionId,
|
|
197
|
+
revision,
|
|
198
|
+
batchCount: row.frame_count,
|
|
199
|
+
headChainHash: row.head_chain_hash,
|
|
200
|
+
manifestDigest: row.manifest_digest,
|
|
201
|
+
lastSeq: row.last_seq,
|
|
202
|
+
updatedAt: now,
|
|
203
|
+
deletedAt: now,
|
|
204
|
+
compression: compressionOf(row.compression),
|
|
205
|
+
storedBytes: row.stored_bytes
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
export function getManifest(db, sessionId) {
|
|
210
|
+
const row = sessionRow(db, sessionId);
|
|
211
|
+
if (row === undefined) return undefined;
|
|
212
|
+
return {
|
|
213
|
+
manifest: JSON.parse(row.manifest_json),
|
|
214
|
+
revision: row.revision
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
export function listBatches(db, sessionId, from, limit) {
|
|
218
|
+
const row = sessionRow(db, sessionId);
|
|
219
|
+
if (row === undefined) return undefined;
|
|
220
|
+
const manifest = JSON.parse(row.manifest_json);
|
|
221
|
+
return {
|
|
222
|
+
batches: manifest.frames.slice(from, from + limit),
|
|
223
|
+
batchCount: manifest.frames.length,
|
|
224
|
+
revision: row.revision
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const V2_SUMMARY_SELECT = `s.session_id, s.created_at, s.format_version, s.last_seq, s.frame_count,
|
|
228
|
+
s.head_chain_hash, s.manifest_digest, s.revision, s.updated_at, s.deleted_at,
|
|
229
|
+
s.compression, s.stored_bytes,
|
|
230
|
+
json_extract(s.manifest_json, '$.workspaceKey') AS workspace_key,
|
|
231
|
+
json_extract(s.manifest_json, '$.tailOpen') AS tail_open,
|
|
232
|
+
json_extract(s.manifest_json, '$.safeThroughSeq') AS safe_through_seq`;
|
|
233
|
+
function mapV2Summary(row) {
|
|
234
|
+
const safe = typeof row.safe_through_seq === 'number' && Number.isSafeInteger(row.safe_through_seq) ? row.safe_through_seq : row.last_seq;
|
|
235
|
+
return {
|
|
236
|
+
sessionId: row.session_id,
|
|
237
|
+
revision: row.revision,
|
|
238
|
+
formatVersion: row.format_version,
|
|
239
|
+
createdAt: row.created_at,
|
|
240
|
+
batchCount: row.frame_count,
|
|
241
|
+
lastSeq: row.last_seq,
|
|
242
|
+
headChainHash: row.head_chain_hash,
|
|
243
|
+
manifestDigest: row.manifest_digest,
|
|
244
|
+
workspaceKey: typeof row.workspace_key === 'string' && row.workspace_key !== '' ? row.workspace_key : null,
|
|
245
|
+
safeThroughSeq: safe,
|
|
246
|
+
tailOpen: Number(row.tail_open) === 1,
|
|
247
|
+
updatedAt: row.updated_at,
|
|
248
|
+
deletedAt: row.deleted_at,
|
|
249
|
+
compression: compressionOf(row.compression),
|
|
250
|
+
storedBytes: Number.isSafeInteger(row.stored_bytes) ? row.stored_bytes : 0
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
export function listV2Sessions(db, after, limit) {
|
|
254
|
+
const rows = db.handle.prepare(`SELECT ${V2_SUMMARY_SELECT}
|
|
255
|
+
FROM sessions s
|
|
256
|
+
WHERE s.dataset = ? AND s.deleted_at IS NULL AND s.session_id > ?
|
|
257
|
+
ORDER BY s.session_id ASC LIMIT ?`).all(DEFAULT_DATASET, after ?? '', limit + 1);
|
|
258
|
+
const visible = rows.slice(0, limit);
|
|
259
|
+
const last = visible[visible.length - 1];
|
|
260
|
+
return {
|
|
261
|
+
sessions: visible.map(mapV2Summary),
|
|
262
|
+
nextSessionId: rows.length > limit && last !== undefined ? last.session_id : null
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
export function listV2Changes(db, sinceRevision, sinceTombstone, limit) {
|
|
266
|
+
const highWater = Math.max(sinceRevision, readRevision(db));
|
|
267
|
+
const rows = db.handle.prepare(`SELECT ${V2_SUMMARY_SELECT}
|
|
268
|
+
FROM sessions s
|
|
269
|
+
WHERE s.dataset = ? AND s.deleted_at IS NULL AND s.revision > ? AND s.revision <= ?
|
|
270
|
+
ORDER BY s.revision ASC LIMIT ?`).all(DEFAULT_DATASET, sinceRevision, highWater, limit + 1);
|
|
271
|
+
const tombstoneRows = db.handle.prepare(`SELECT session_id, created_at, deleted_at FROM tombstones
|
|
272
|
+
WHERE dataset = ? AND (
|
|
273
|
+
deleted_at > ? OR
|
|
274
|
+
(deleted_at = ? AND session_id > ?) OR
|
|
275
|
+
(deleted_at = ? AND session_id = ? AND created_at > ?)
|
|
276
|
+
)
|
|
277
|
+
ORDER BY deleted_at ASC, session_id ASC, created_at ASC LIMIT ?`).all(DEFAULT_DATASET, sinceTombstone.deletedAt, sinceTombstone.deletedAt, sinceTombstone.sessionId, sinceTombstone.deletedAt, sinceTombstone.sessionId, sinceTombstone.createdAt, limit + 1);
|
|
278
|
+
const visible = rows.slice(0, limit);
|
|
279
|
+
const visibleTombstones = tombstoneRows.slice(0, limit);
|
|
280
|
+
const last = visible[visible.length - 1];
|
|
281
|
+
const lastTombstone = visibleTombstones[visibleTombstones.length - 1];
|
|
282
|
+
const hasMore = rows.length > limit || tombstoneRows.length > limit;
|
|
283
|
+
return {
|
|
284
|
+
sessions: visible.map(mapV2Summary),
|
|
285
|
+
tombstones: visibleTombstones.map((row)=>({
|
|
286
|
+
sessionId: row.session_id,
|
|
287
|
+
createdAt: row.created_at,
|
|
288
|
+
deletedAt: row.deleted_at
|
|
289
|
+
})),
|
|
290
|
+
nextRevision: rows.length > limit && last !== undefined ? last.revision : highWater,
|
|
291
|
+
nextTombstone: lastTombstone === undefined ? sinceTombstone : {
|
|
292
|
+
deletedAt: lastTombstone.deleted_at,
|
|
293
|
+
sessionId: lastTombstone.session_id,
|
|
294
|
+
createdAt: lastTombstone.created_at
|
|
295
|
+
},
|
|
296
|
+
hasMore
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
export function getV2SessionSummary(db, sessionId) {
|
|
300
|
+
const row = db.handle.prepare(`SELECT ${V2_SUMMARY_SELECT}
|
|
301
|
+
FROM sessions s
|
|
302
|
+
WHERE s.dataset = ? AND s.session_id = ?`).get(DEFAULT_DATASET, sessionId);
|
|
303
|
+
return row === undefined ? undefined : mapV2Summary(row);
|
|
304
|
+
}
|
|
305
|
+
export function listV2Batches(db, sessionId, from, limit) {
|
|
306
|
+
const row = sessionRow(db, sessionId);
|
|
307
|
+
if (row === undefined) return undefined;
|
|
308
|
+
const manifest = JSON.parse(row.manifest_json);
|
|
309
|
+
return {
|
|
310
|
+
batches: manifest.frames.slice(from, from + limit),
|
|
311
|
+
batchCount: manifest.frames.length,
|
|
312
|
+
revision: row.revision,
|
|
313
|
+
manifestDigest: row.manifest_digest
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
export function adminSessions(db, limit = 500) {
|
|
317
|
+
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,
|
|
318
|
+
s.revision, s.updated_at, s.deleted_at,
|
|
319
|
+
json_extract(s.manifest_json, '$.workspaceKey') AS workspace_key
|
|
320
|
+
FROM sessions s
|
|
321
|
+
WHERE s.dataset = ? ORDER BY s.updated_at DESC LIMIT ?`).all(DEFAULT_DATASET, limit);
|
|
322
|
+
return rows.map((row)=>({
|
|
323
|
+
sessionId: row.session_id,
|
|
324
|
+
createdAt: row.created_at,
|
|
325
|
+
formatVersion: row.format_version,
|
|
326
|
+
batchCount: row.frame_count,
|
|
327
|
+
lastSeq: row.last_seq,
|
|
328
|
+
headChainHash: row.head_chain_hash,
|
|
329
|
+
revision: row.revision,
|
|
330
|
+
updatedAt: row.updated_at,
|
|
331
|
+
deletedAt: row.deleted_at,
|
|
332
|
+
workspaceKey: row.workspace_key === null ? null : String(row.workspace_key)
|
|
333
|
+
}));
|
|
334
|
+
}
|
|
335
|
+
export function sessionCounts(db) {
|
|
336
|
+
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);
|
|
337
|
+
return {
|
|
338
|
+
total: Number(row?.total ?? 0),
|
|
339
|
+
deleted: Number(row?.deleted ?? 0)
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
export function listChanges(db, since, limit) {
|
|
343
|
+
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);
|
|
344
|
+
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);
|
|
345
|
+
const sessions = rows.slice(0, limit).map((row)=>({
|
|
346
|
+
sessionId: row.session_id,
|
|
347
|
+
revision: row.revision,
|
|
348
|
+
formatVersion: row.format_version,
|
|
349
|
+
createdAt: row.created_at,
|
|
350
|
+
batchCount: row.frame_count,
|
|
351
|
+
headChainHash: row.head_chain_hash,
|
|
352
|
+
manifestDigest: row.manifest_digest,
|
|
353
|
+
updatedAt: row.updated_at,
|
|
354
|
+
deletedAt: row.deleted_at
|
|
355
|
+
}));
|
|
356
|
+
return {
|
|
357
|
+
revision: readRevision(db),
|
|
358
|
+
sessions,
|
|
359
|
+
tombstones: tombstones.slice(0, limit).map((row)=>({
|
|
360
|
+
sessionId: row.session_id,
|
|
361
|
+
createdAt: row.created_at,
|
|
362
|
+
deletedAt: row.deleted_at
|
|
363
|
+
})),
|
|
364
|
+
hasMore: rows.length > limit || tombstones.length > limit
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
//# sourceURL=sessions.ts
|