dscs-server 0.1.2 → 0.1.4

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/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
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';
5
+ import { generateAdminPassword, resetAdminPassword } from './admin-password.js';
6
+ import { ensureSetupToken, isAdminInitialized, openDatabase, readMaxBytes } from './db.js';
6
7
  function writeOnboardingCode(dataDir, code) {
7
8
  mkdirSync(dataDir, {
8
9
  recursive: true
@@ -34,13 +35,15 @@ function usage() {
34
35
  token list 列出存活设备
35
36
  token revoke <deviceId> 吊销某个设备 token
36
37
  admin status 查看初始化状态与配额
37
- admin reset 忘记管理密码时重置(重启 serve 后重新初始化)
38
+ admin reset-password 忘记管理密码时重置为新密码
39
+ (默认生成随机密码;--stdin 从标准输入读取自定义密码)
38
40
 
39
41
  选项:
40
42
  --data-dir <目录> 数据目录(默认 .tmp/dev-data)
41
43
  --host <地址> 绑定地址(默认 127.0.0.1)
42
44
  --port <端口> 监听端口(默认 8787)
43
45
  --max-bytes <n> 存储上限(默认 10 GiB)
46
+ --stdin admin reset-password 从标准输入读取新密码
44
47
  `);
45
48
  process.exit(2);
46
49
  }
@@ -50,6 +53,10 @@ function parse(argv) {
50
53
  for(let index = 0; index < argv.length; index += 1){
51
54
  const argument = argv[index];
52
55
  if (argument.startsWith('--')) {
56
+ if (argument === '--stdin') {
57
+ flags.set('stdin', '');
58
+ continue;
59
+ }
53
60
  const value = argv[index + 1];
54
61
  if (value === undefined || value.startsWith('--')) usage();
55
62
  flags.set(argument.slice(2), value);
@@ -99,10 +106,19 @@ async function main(parsed) {
99
106
  }, null, 2));
100
107
  return;
101
108
  }
102
- if (action === 'reset') {
103
- clearAdminPassword(db);
104
- clearOnboardingCode(dataDir);
105
- console.log('已清除管理密码。重启 server 后会在控制台打印新的初始化密钥。');
109
+ if (action === 'reset-password') {
110
+ const fromStdin = parsed.flags.has('stdin');
111
+ const password = fromStdin ? readFileSync(0, 'utf8').replace(/\r?\n$/u, '') : generateAdminPassword();
112
+ resetAdminPassword(db, password);
113
+ if (fromStdin) {
114
+ console.log('已重置管理密码。旧管理会话需要重启 server 后失效。');
115
+ } else {
116
+ console.log('已重置管理密码(只显示一次):');
117
+ console.log('');
118
+ console.log(password);
119
+ console.log('');
120
+ console.log('请登录管理台后立即修改;旧管理会话需要重启 server 后失效。');
121
+ }
106
122
  return;
107
123
  }
108
124
  usage();
@@ -0,0 +1,200 @@
1
+ import { rmSync } from 'node:fs';
2
+ import { blobPath } from './blobs.js';
3
+ import { bumpRevision, DEFAULT_DATASET, withTransaction } from './db.js';
4
+ import { SyncError } from './shared/wire.js';
5
+ import { recordSessionEvent } from './session-history.js';
6
+ export const SESSION_RESET_TTL_MS = 24 * 60 * 60 * 1000;
7
+ function targetRow(db, sessionId, createdAt) {
8
+ return db.handle.prepare('SELECT session_id, created_at, deleted_at, purged_at, stored_bytes, last_seq, manifest_json FROM sessions WHERE dataset = ? AND session_id = ?').get(DEFAULT_DATASET, sessionId);
9
+ }
10
+ function requireTarget(db, sessionId, createdAt) {
11
+ const row = targetRow(db, sessionId, createdAt);
12
+ if (row === undefined) throw new SyncError('not-found', `没有会话 ${sessionId}`);
13
+ if (row.created_at !== createdAt) throw new SyncError('conflict', 'createdAt 与当前生命周期不符');
14
+ return row;
15
+ }
16
+ function purgeSessionBlobs(db, dataDir, sessionId, manifestJson) {
17
+ let hashes;
18
+ try {
19
+ const manifest = JSON.parse(manifestJson);
20
+ hashes = (Array.isArray(manifest.frames) ? manifest.frames : []).map((frame)=>frame !== null && typeof frame === 'object' ? frame.blobSha256 : undefined).filter((hash)=>typeof hash === 'string' && hash !== '');
21
+ } catch {
22
+ throw new SyncError('conflict', '会话 manifest 损坏,拒绝清理');
23
+ }
24
+ const unique = [
25
+ ...new Set(hashes)
26
+ ];
27
+ if (unique.length === 0) return {
28
+ bytes: 0,
29
+ files: []
30
+ };
31
+ const otherHashes = new Set();
32
+ const otherRows = db.handle.prepare('SELECT manifest_json FROM sessions WHERE dataset = ? AND session_id <> ?').all(DEFAULT_DATASET, sessionId);
33
+ for (const row of otherRows){
34
+ try {
35
+ const manifest = JSON.parse(row.manifest_json);
36
+ for (const frame of Array.isArray(manifest.frames) ? manifest.frames : []){
37
+ if (frame !== null && typeof frame === 'object' && typeof frame.blobSha256 === 'string') otherHashes.add(frame.blobSha256);
38
+ }
39
+ } catch {
40
+ for (const hash of unique)otherHashes.add(hash);
41
+ }
42
+ }
43
+ const vaultCheck = db.handle.prepare('SELECT 1 FROM vault_records WHERE blob_sha256 = ? LIMIT 1');
44
+ const deleteBlob = db.handle.prepare('DELETE FROM blobs WHERE sha256 = ?');
45
+ const blobInfo = db.handle.prepare('SELECT bytes FROM blobs WHERE sha256 = ?');
46
+ let bytes = 0;
47
+ const files = [];
48
+ for (const hash of unique){
49
+ if (otherHashes.has(hash) || vaultCheck.get(hash) !== undefined) continue;
50
+ const record = blobInfo.get(hash);
51
+ if (record === undefined) continue;
52
+ deleteBlob.run(hash);
53
+ bytes += Number(record.bytes ?? 0);
54
+ files.push(blobPath(dataDir, hash));
55
+ }
56
+ return {
57
+ bytes,
58
+ files
59
+ };
60
+ }
61
+ function removeFiles(files) {
62
+ for (const file of files){
63
+ try {
64
+ rmSync(file, {
65
+ force: true
66
+ });
67
+ } catch {}
68
+ }
69
+ }
70
+ export function archiveSession(db, sessionId, createdAt, now = Date.now()) {
71
+ withTransaction(db, ()=>{
72
+ const row = requireTarget(db, sessionId, createdAt);
73
+ if (row.deleted_at !== null) throw new SyncError('conflict', '会话已经删除/存档');
74
+ const revision = bumpRevision(db);
75
+ db.handle.prepare('UPDATE sessions SET deleted_at = ?, revision = ?, updated_at = ? WHERE dataset = ? AND session_id = ?').run(now, revision, now, DEFAULT_DATASET, sessionId);
76
+ db.handle.prepare(`INSERT OR REPLACE INTO tombstones (dataset, session_id, created_at, deleted_at, deleted_by)
77
+ VALUES (?, ?, ?, ?, ?)`).run(DEFAULT_DATASET, sessionId, createdAt, now, 'admin:archive');
78
+ db.handle.prepare('DELETE FROM append_requests WHERE dataset = ? AND session_id = ?').run(DEFAULT_DATASET, sessionId);
79
+ recordSessionEvent(db, {
80
+ sessionId,
81
+ at: now,
82
+ kind: 'archive',
83
+ actorKind: 'admin',
84
+ actorId: 'admin',
85
+ lastSeq: row.last_seq ?? null,
86
+ storedBytes: row.stored_bytes ?? null,
87
+ detail: {
88
+ createdAt
89
+ }
90
+ });
91
+ });
92
+ }
93
+ export function removeSession(db, dataDir, sessionId, createdAt, now = Date.now()) {
94
+ let purged;
95
+ withTransaction(db, ()=>{
96
+ const row = requireTarget(db, sessionId, createdAt);
97
+ if (row.purged_at !== null) throw new SyncError('conflict', '会话已经移除并释放过空间');
98
+ purged = purgeSessionBlobs(db, dataDir, sessionId, row.manifest_json);
99
+ const revision = bumpRevision(db);
100
+ let manifest = {};
101
+ try {
102
+ manifest = JSON.parse(row.manifest_json);
103
+ } catch {
104
+ manifest = {};
105
+ }
106
+ manifest.frames = [];
107
+ db.handle.prepare(`UPDATE sessions
108
+ SET deleted_at = COALESCE(deleted_at, ?), purged_at = ?, revision = ?, updated_at = ?,
109
+ stored_bytes = 0, manifest_json = ?
110
+ WHERE dataset = ? AND session_id = ?`).run(now, now, revision, now, JSON.stringify(manifest), DEFAULT_DATASET, sessionId);
111
+ db.handle.prepare(`INSERT OR REPLACE INTO tombstones (dataset, session_id, created_at, deleted_at, deleted_by)
112
+ VALUES (?, ?, ?, ?, ?)`).run(DEFAULT_DATASET, sessionId, createdAt, now, 'admin:remove');
113
+ db.handle.prepare('DELETE FROM append_requests WHERE dataset = ? AND session_id = ?').run(DEFAULT_DATASET, sessionId);
114
+ recordSessionEvent(db, {
115
+ sessionId,
116
+ at: now,
117
+ kind: 'remove',
118
+ actorKind: 'admin',
119
+ actorId: 'admin',
120
+ bytes: purged?.bytes ?? 0,
121
+ lastSeq: null,
122
+ storedBytes: 0,
123
+ detail: {
124
+ createdAt,
125
+ purgedBytes: purged?.bytes ?? 0
126
+ }
127
+ });
128
+ });
129
+ const result = purged ?? {
130
+ bytes: 0,
131
+ files: []
132
+ };
133
+ removeFiles(result.files);
134
+ return result.bytes;
135
+ }
136
+ export function resetSession(db, dataDir, sessionId, createdAt, deviceId, now = Date.now()) {
137
+ let purged;
138
+ withTransaction(db, ()=>{
139
+ const row = requireTarget(db, sessionId, createdAt);
140
+ purged = purgeSessionBlobs(db, dataDir, sessionId, row.manifest_json);
141
+ db.handle.prepare('DELETE FROM append_requests WHERE dataset = ? AND session_id = ?').run(DEFAULT_DATASET, sessionId);
142
+ db.handle.prepare('DELETE FROM tombstones WHERE dataset = ? AND session_id = ?').run(DEFAULT_DATASET, sessionId);
143
+ db.handle.prepare('DELETE FROM sessions WHERE dataset = ? AND session_id = ?').run(DEFAULT_DATASET, sessionId);
144
+ db.handle.prepare('DELETE FROM leases WHERE dataset = ? AND resource = ?').run(DEFAULT_DATASET, 'sync');
145
+ let workspaceKey = null;
146
+ try {
147
+ const manifest = JSON.parse(row.manifest_json);
148
+ if (typeof manifest.workspaceKey === 'string' && manifest.workspaceKey !== '') workspaceKey = manifest.workspaceKey;
149
+ } catch {
150
+ workspaceKey = null;
151
+ }
152
+ db.handle.prepare(`INSERT OR REPLACE INTO session_resets
153
+ (dataset, session_id, created_at, reset_at, expires_at, device_id, workspace_key)
154
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(DEFAULT_DATASET, sessionId, createdAt, now, now + SESSION_RESET_TTL_MS, deviceId, workspaceKey);
155
+ recordSessionEvent(db, {
156
+ sessionId,
157
+ at: now,
158
+ kind: 'reset',
159
+ actorKind: 'admin',
160
+ actorId: 'admin',
161
+ bytes: purged?.bytes ?? 0,
162
+ detail: {
163
+ createdAt,
164
+ deviceId,
165
+ purgedBytes: purged?.bytes ?? 0
166
+ }
167
+ });
168
+ });
169
+ const result = purged ?? {
170
+ bytes: 0,
171
+ files: []
172
+ };
173
+ removeFiles(result.files);
174
+ return result.bytes;
175
+ }
176
+ export function restoreSession(db, sessionId, createdAt, now = Date.now()) {
177
+ withTransaction(db, ()=>{
178
+ const row = requireTarget(db, sessionId, createdAt);
179
+ if (row.deleted_at === null) throw new SyncError('conflict', '会话没有存档');
180
+ if (row.purged_at !== null) throw new SyncError('conflict', '会话已经释放空间,不能恢复');
181
+ const revision = bumpRevision(db);
182
+ db.handle.prepare('UPDATE sessions SET deleted_at = NULL, revision = ?, updated_at = ? WHERE dataset = ? AND session_id = ?').run(revision, now, DEFAULT_DATASET, sessionId);
183
+ db.handle.prepare('DELETE FROM tombstones WHERE dataset = ? AND session_id = ? AND created_at = ?').run(DEFAULT_DATASET, sessionId, createdAt);
184
+ recordSessionEvent(db, {
185
+ sessionId,
186
+ at: now,
187
+ kind: 'restore',
188
+ actorKind: 'admin',
189
+ actorId: 'admin',
190
+ lastSeq: null,
191
+ storedBytes: row.stored_bytes ?? null,
192
+ detail: {
193
+ createdAt
194
+ }
195
+ });
196
+ });
197
+ }
198
+
199
+
200
+ //# sourceURL=session-admin.ts
@@ -0,0 +1,134 @@
1
+ import { DEFAULT_DATASET } from './db.js';
2
+ export function recordSessionEvent(db, input) {
3
+ db.handle.prepare(`INSERT INTO session_events
4
+ (dataset, session_id, at, kind, device_id, actor_kind, actor_id, events, bytes, seq_from, seq_to,
5
+ batch_count, last_seq, stored_bytes, detail)
6
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(DEFAULT_DATASET, input.sessionId, input.at, input.kind, input.deviceId ?? null, input.actorKind, input.actorId, Math.max(0, Math.trunc(input.events ?? 0)), Math.max(0, Math.trunc(input.bytes ?? 0)), input.seqFrom ?? null, input.seqTo ?? null, Math.max(0, Math.trunc(input.batchCount ?? 0)), input.lastSeq ?? null, input.storedBytes ?? null, input.detail === undefined ? null : JSON.stringify(input.detail));
7
+ }
8
+ export function listSessionHistory(db, sessionId, options = {}) {
9
+ const limit = Math.min(200, Math.max(1, Math.trunc(options.limit ?? 100)));
10
+ const before = options.before !== undefined && Number.isSafeInteger(options.before) ? options.before : undefined;
11
+ const rows = db.handle.prepare(`SELECT e.id, e.session_id, e.at, e.kind, e.device_id, e.actor_kind, e.actor_id,
12
+ e.events, e.bytes, e.seq_from, e.seq_to, e.batch_count, e.last_seq, e.stored_bytes, e.detail,
13
+ d.label AS device_label, d.color AS device_color
14
+ FROM session_events e
15
+ LEFT JOIN devices d ON d.dataset = e.dataset AND d.device_id = e.device_id
16
+ WHERE e.dataset = ? AND e.session_id = ? ${before === undefined ? '' : 'AND e.id < ?'}
17
+ ORDER BY e.id DESC LIMIT ?`).all(...[
18
+ DEFAULT_DATASET,
19
+ sessionId,
20
+ ...before === undefined ? [] : [
21
+ before
22
+ ],
23
+ limit
24
+ ]);
25
+ const events = rows.map((row)=>({
26
+ id: row.id,
27
+ sessionId: row.session_id,
28
+ at: row.at,
29
+ kind: row.kind,
30
+ deviceId: row.device_id,
31
+ deviceLabel: row.device_label,
32
+ deviceColor: row.device_color,
33
+ actorKind: row.actor_kind,
34
+ actorId: row.actor_id,
35
+ actorLabel: row.actor_kind === 'admin' ? '管理台' : row.actor_kind === 'system' ? '系统' : row.device_label ?? row.actor_id,
36
+ events: Number(row.events ?? 0),
37
+ bytes: Number(row.bytes ?? 0),
38
+ seqFrom: row.seq_from,
39
+ seqTo: row.seq_to,
40
+ batchCount: Number(row.batch_count ?? 0),
41
+ lastSeq: row.last_seq,
42
+ storedBytes: row.stored_bytes,
43
+ detail: row.detail === null ? null : parseDetail(row.detail)
44
+ }));
45
+ const summaryRow = db.handle.prepare(`SELECT COALESCE(SUM(events), 0) AS events, COALESCE(SUM(bytes), 0) AS bytes,
46
+ COALESCE(SUM(batch_count), 0) AS batches,
47
+ COALESCE(SUM(CASE WHEN kind IN ('create', 'append') THEN 1 ELSE 0 END), 0) AS appends,
48
+ COUNT(DISTINCT device_id) AS devices,
49
+ MIN(at) AS first_at, MAX(at) AS last_at,
50
+ COALESCE(MAX(events), 0) AS max_batch_events
51
+ FROM session_events WHERE dataset = ? AND session_id = ?`).get(DEFAULT_DATASET, sessionId);
52
+ return {
53
+ events,
54
+ summary: {
55
+ events: Number(summaryRow?.events ?? 0),
56
+ bytes: Number(summaryRow?.bytes ?? 0),
57
+ batches: Number(summaryRow?.batches ?? 0),
58
+ appends: Number(summaryRow?.appends ?? 0),
59
+ devices: Number(summaryRow?.devices ?? 0),
60
+ firstAt: summaryRow?.first_at ?? null,
61
+ lastAt: summaryRow?.last_at ?? null,
62
+ maxBatchEvents: Number(summaryRow?.max_batch_events ?? 0)
63
+ },
64
+ nextBefore: rows.length === limit ? rows[rows.length - 1].id : null
65
+ };
66
+ }
67
+ export function listActivity(db, query) {
68
+ const offsetMs = Math.max(-14 * 60, Math.min(14 * 60, Math.trunc(query.tzOffset ?? 0))) * 60_000;
69
+ const where = [
70
+ 'e.dataset = ?',
71
+ "e.kind IN ('create', 'append')",
72
+ 'e.at >= ?',
73
+ 'e.at < ?'
74
+ ];
75
+ const params = [
76
+ DEFAULT_DATASET,
77
+ Math.trunc(query.from),
78
+ Math.trunc(query.to)
79
+ ];
80
+ if (query.deviceId !== undefined && query.deviceId !== '') {
81
+ where.push('e.device_id = ?');
82
+ params.push(query.deviceId);
83
+ }
84
+ if (query.sessionId !== undefined && query.sessionId !== '') {
85
+ where.push('e.session_id = ?');
86
+ params.push(query.sessionId);
87
+ }
88
+ const rows = db.handle.prepare(`SELECT date((e.at + ?) / 1000, 'unixepoch') AS day,
89
+ COALESCE(SUM(e.events), 0) AS events,
90
+ COALESCE(SUM(e.bytes), 0) AS bytes,
91
+ COUNT(*) AS appends,
92
+ COUNT(DISTINCT e.session_id) AS sessions,
93
+ COUNT(DISTINCT e.device_id) AS devices
94
+ FROM session_events e
95
+ WHERE ${where.join(' AND ')}
96
+ GROUP BY day ORDER BY day ASC`).all(offsetMs, ...params);
97
+ return {
98
+ days: rows.map((row)=>({
99
+ day: row.day,
100
+ events: Number(row.events ?? 0),
101
+ bytes: Number(row.bytes ?? 0),
102
+ appends: Number(row.appends ?? 0),
103
+ sessions: Number(row.sessions ?? 0),
104
+ devices: Number(row.devices ?? 0)
105
+ }))
106
+ };
107
+ }
108
+ export function listActivitySessions(db, query) {
109
+ const offsetMs = Math.max(-14 * 60, Math.min(14 * 60, Math.trunc(query.tzOffset ?? 0))) * 60_000;
110
+ const rows = db.handle.prepare(`SELECT e.session_id, COALESCE(SUM(e.events), 0) AS events,
111
+ COALESCE(SUM(e.bytes), 0) AS bytes, COUNT(*) AS appends
112
+ FROM session_events e
113
+ WHERE e.dataset = ? AND e.kind IN ('create', 'append')
114
+ AND date((e.at + ?) / 1000, 'unixepoch') = ?
115
+ GROUP BY e.session_id ORDER BY events DESC`).all(DEFAULT_DATASET, offsetMs, query.day);
116
+ return {
117
+ sessions: rows.map((row)=>({
118
+ sessionId: row.session_id,
119
+ events: Number(row.events ?? 0),
120
+ bytes: Number(row.bytes ?? 0),
121
+ appends: Number(row.appends ?? 0)
122
+ }))
123
+ };
124
+ }
125
+ function parseDetail(raw) {
126
+ try {
127
+ return JSON.parse(raw);
128
+ } catch {
129
+ return raw;
130
+ }
131
+ }
132
+
133
+
134
+ //# sourceURL=session-history.ts
package/dist/sessions.js CHANGED
@@ -3,8 +3,9 @@ import { assertChain, buildManifest, manifestDigest, stableStringify } from './s
3
3
  import { sha256 } from './shared/crypto.js';
4
4
  import { SyncError } from './shared/wire.js';
5
5
  import { blobPath } from './blobs.js';
6
- import { DEFAULT_DATASET, bumpRevision, readAppendRequest, readRevision, withTransaction, writeAppendRequest } from './db.js';
6
+ import { DEFAULT_DATASET, bumpRevision, readAppendRequest, withTransaction, writeAppendRequest } from './db.js';
7
7
  import { requireLease as requireResourceLease, SYNC_RESOURCE } from './leases.js';
8
+ import { recordSessionEvent } from './session-history.js';
8
9
  export const MAX_APPEND_BODY_BYTES = 8 * 1024 * 1024;
9
10
  const HASH = /^[0-9a-f]{64}$/u;
10
11
  const KEY_ID = /^[0-9a-f]{16}$/u;
@@ -37,7 +38,18 @@ function toChain(frames) {
37
38
  }));
38
39
  }
39
40
  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
+ 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, purged_at, compression, stored_bytes FROM sessions WHERE dataset = ? AND session_id = ?').get(DEFAULT_DATASET, sessionId);
42
+ }
43
+ function activeReset(db, sessionId, now) {
44
+ const row = db.handle.prepare('SELECT device_id, expires_at FROM session_resets WHERE dataset = ? AND session_id = ?').get(DEFAULT_DATASET, sessionId);
45
+ if (row === undefined) return undefined;
46
+ if (row.expires_at <= now) {
47
+ db.handle.prepare('DELETE FROM session_resets WHERE dataset = ? AND session_id = ?').run(DEFAULT_DATASET, sessionId);
48
+ return undefined;
49
+ }
50
+ return {
51
+ device_id: row.device_id
52
+ };
41
53
  }
42
54
  function manifestOf(sessionId, formatVersion, createdAt, updatedAt, frames, tailOpen, safeThroughSeq, workspaceKey, parentSession, origin, delegationDepth) {
43
55
  assertChain(toChain(frames));
@@ -105,6 +117,10 @@ function appendRequestHash(input) {
105
117
  }
106
118
  export function appendFrames(db, dataDir, input, now = Date.now()) {
107
119
  return withTransaction(db, ()=>{
120
+ const reset = activeReset(db, input.sessionId, now);
121
+ if (reset !== undefined && reset.device_id !== null && reset.device_id !== input.deviceId) {
122
+ throw new SyncError('reset-pending', '该会话正在等待指定设备重建');
123
+ }
108
124
  const idempotencyKey = input.idempotencyKey;
109
125
  const requestHash = idempotencyKey === undefined ? undefined : appendRequestHash(input);
110
126
  if (idempotencyKey !== undefined && requestHash !== undefined) {
@@ -125,6 +141,9 @@ export function appendFrames(db, dataDir, input, now = Date.now()) {
125
141
  let parentSession = input.parentSession;
126
142
  let origin = input.origin;
127
143
  let delegationDepth = input.delegationDepth;
144
+ if (row !== undefined && row.purged_at !== null) {
145
+ throw new SyncError('gone', `会话 ${input.sessionId} 已移除并释放空间,需要由管理台重置后才能重建`);
146
+ }
128
147
  if (row !== undefined && row.deleted_at !== null && row.created_at === input.createdAt) {
129
148
  throw new SyncError('gone', `会话 ${input.sessionId} 的这个生命周期已被删除`);
130
149
  }
@@ -183,13 +202,13 @@ export function appendFrames(db, dataDir, input, now = Date.now()) {
183
202
  const { manifest, digest, headChainHash } = manifestOf(input.sessionId, formatVersion, createdAt, now, combined, input.tailOpen, input.safeThroughSeq, workspaceKey, parentSession, origin, delegationDepth);
184
203
  const revision = bumpRevision(db);
185
204
  const stats = statsOf(combined);
186
- 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)
187
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
205
+ 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, purged_at, compression, stored_bytes)
206
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)
188
207
  ON CONFLICT(dataset, session_id) DO UPDATE SET
189
208
  created_at = excluded.created_at, format_version = excluded.format_version, last_seq = excluded.last_seq,
190
209
  frame_count = excluded.frame_count, head_chain_hash = excluded.head_chain_hash,
191
210
  manifest_json = excluded.manifest_json, manifest_digest = excluded.manifest_digest,
192
- revision = excluded.revision, updated_at = excluded.updated_at, deleted_at = NULL,
211
+ revision = excluded.revision, updated_at = excluded.updated_at, deleted_at = NULL, purged_at = NULL,
193
212
  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);
194
213
  const summary = {
195
214
  sessionId: input.sessionId,
@@ -206,6 +225,37 @@ export function appendFrames(db, dataDir, input, now = Date.now()) {
206
225
  if (idempotencyKey !== undefined && requestHash !== undefined) {
207
226
  writeAppendRequest(db, input.deviceId, input.sessionId, idempotencyKey, requestHash, JSON.stringify(summary), now);
208
227
  }
228
+ if (reset !== undefined) {
229
+ db.handle.prepare('DELETE FROM session_resets WHERE dataset = ? AND session_id = ?').run(DEFAULT_DATASET, input.sessionId);
230
+ }
231
+ const created = row === undefined || replacingLifecycle;
232
+ const oldSeq = created ? 0 : row.last_seq;
233
+ const oldBytes = created ? 0 : row.stored_bytes;
234
+ const tailReplaced = !created && input.baseIndex < row.frame_count - 1;
235
+ recordSessionEvent(db, {
236
+ sessionId: input.sessionId,
237
+ at: now,
238
+ kind: created ? 'create' : 'append',
239
+ deviceId: input.deviceId,
240
+ actorKind: 'device',
241
+ actorId: input.deviceId,
242
+ events: tailReplaced ? 0 : Math.max(0, summary.lastSeq - oldSeq),
243
+ bytes: Math.max(0, summary.storedBytes - oldBytes),
244
+ seqFrom: input.frames[0]?.seqFrom ?? null,
245
+ seqTo: input.frames[input.frames.length - 1]?.seqTo ?? null,
246
+ batchCount: input.frames.length,
247
+ lastSeq: summary.lastSeq,
248
+ storedBytes: summary.storedBytes,
249
+ detail: {
250
+ createdAt,
251
+ ...tailReplaced ? {
252
+ tailReplaced: true
253
+ } : {},
254
+ ...workspaceKey === undefined ? {} : {
255
+ workspaceKey
256
+ }
257
+ }
258
+ });
209
259
  return summary;
210
260
  });
211
261
  }
@@ -219,6 +269,19 @@ export function deleteSession(db, sessionId, deviceId, clientId, createdAt, now
219
269
  const revision = bumpRevision(db);
220
270
  db.handle.prepare('UPDATE sessions SET deleted_at = ?, revision = ?, updated_at = ? WHERE dataset = ? AND session_id = ?').run(now, revision, now, DEFAULT_DATASET, sessionId);
221
271
  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);
272
+ recordSessionEvent(db, {
273
+ sessionId,
274
+ at: now,
275
+ kind: 'device-delete',
276
+ deviceId,
277
+ actorKind: 'device',
278
+ actorId: deviceId,
279
+ lastSeq: row.last_seq,
280
+ storedBytes: row.stored_bytes,
281
+ detail: {
282
+ createdAt
283
+ }
284
+ });
222
285
  return {
223
286
  sessionId,
224
287
  revision,
@@ -253,7 +316,10 @@ export function listBatches(db, sessionId, from, limit) {
253
316
  }
254
317
  const V2_SUMMARY_SELECT = `s.session_id, s.created_at, s.format_version, s.last_seq, s.frame_count,
255
318
  s.head_chain_hash, s.manifest_digest, s.revision, s.updated_at, s.deleted_at,
256
- s.compression, s.stored_bytes,
319
+ s.purged_at, s.compression, s.stored_bytes,
320
+ (SELECT t.deleted_by FROM tombstones t
321
+ WHERE t.dataset = s.dataset AND t.session_id = s.session_id AND t.created_at = s.created_at
322
+ LIMIT 1) AS deleted_by,
257
323
  json_extract(s.manifest_json, '$.workspaceKey') AS workspace_key,
258
324
  json_extract(s.manifest_json, '$.tailOpen') AS tail_open,
259
325
  json_extract(s.manifest_json, '$.safeThroughSeq') AS safe_through_seq,
@@ -279,15 +345,17 @@ function mapV2Summary(row) {
279
345
  delegationDepth: typeof row.delegation_depth === 'number' && Number.isSafeInteger(row.delegation_depth) ? row.delegation_depth : null,
280
346
  updatedAt: row.updated_at,
281
347
  deletedAt: row.deleted_at,
348
+ deletedBy: row.deleted_by,
349
+ purgedAt: row.purged_at,
282
350
  compression: compressionOf(row.compression),
283
351
  storedBytes: Number.isSafeInteger(row.stored_bytes) ? row.stored_bytes : 0
284
352
  };
285
353
  }
286
- export function listV2Sessions(db, after, limit) {
354
+ export function listV2Sessions(db, after, limit, includeDeleted = false) {
287
355
  const rows = db.handle.prepare(`SELECT ${V2_SUMMARY_SELECT}
288
356
  FROM sessions s
289
- WHERE s.dataset = ? AND s.deleted_at IS NULL AND s.session_id > ?
290
- ORDER BY s.session_id ASC LIMIT ?`).all(DEFAULT_DATASET, after ?? '', limit + 1);
357
+ WHERE s.dataset = ? AND (? = 1 OR s.deleted_at IS NULL) AND s.session_id > ?
358
+ ORDER BY s.session_id ASC LIMIT ?`).all(DEFAULT_DATASET, includeDeleted ? 1 : 0, after ?? '', limit + 1);
291
359
  const visible = rows.slice(0, limit);
292
360
  const last = visible[visible.length - 1];
293
361
  return {
@@ -295,40 +363,6 @@ export function listV2Sessions(db, after, limit) {
295
363
  nextSessionId: rows.length > limit && last !== undefined ? last.session_id : null
296
364
  };
297
365
  }
298
- export function listV2Changes(db, sinceRevision, sinceTombstone, limit) {
299
- const highWater = Math.max(sinceRevision, readRevision(db));
300
- const rows = db.handle.prepare(`SELECT ${V2_SUMMARY_SELECT}
301
- FROM sessions s
302
- WHERE s.dataset = ? AND s.deleted_at IS NULL AND s.revision > ? AND s.revision <= ?
303
- ORDER BY s.revision ASC LIMIT ?`).all(DEFAULT_DATASET, sinceRevision, highWater, limit + 1);
304
- const tombstoneRows = db.handle.prepare(`SELECT session_id, created_at, deleted_at FROM tombstones
305
- WHERE dataset = ? AND (
306
- deleted_at > ? OR
307
- (deleted_at = ? AND session_id > ?) OR
308
- (deleted_at = ? AND session_id = ? AND created_at > ?)
309
- )
310
- 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);
311
- const visible = rows.slice(0, limit);
312
- const visibleTombstones = tombstoneRows.slice(0, limit);
313
- const last = visible[visible.length - 1];
314
- const lastTombstone = visibleTombstones[visibleTombstones.length - 1];
315
- const hasMore = rows.length > limit || tombstoneRows.length > limit;
316
- return {
317
- sessions: visible.map(mapV2Summary),
318
- tombstones: visibleTombstones.map((row)=>({
319
- sessionId: row.session_id,
320
- createdAt: row.created_at,
321
- deletedAt: row.deleted_at
322
- })),
323
- nextRevision: rows.length > limit && last !== undefined ? last.revision : highWater,
324
- nextTombstone: lastTombstone === undefined ? sinceTombstone : {
325
- deletedAt: lastTombstone.deleted_at,
326
- sessionId: lastTombstone.session_id,
327
- createdAt: lastTombstone.created_at
328
- },
329
- hasMore
330
- };
331
- }
332
366
  export function getV2SessionSummary(db, sessionId) {
333
367
  const row = db.handle.prepare(`SELECT ${V2_SUMMARY_SELECT}
334
368
  FROM sessions s
@@ -347,11 +381,26 @@ export function listV2Batches(db, sessionId, from, limit) {
347
381
  };
348
382
  }
349
383
  export function adminSessions(db, limit = 500) {
350
- 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,
351
- s.revision, s.updated_at, s.deleted_at,
352
- json_extract(s.manifest_json, '$.workspaceKey') AS workspace_key
353
- FROM sessions s
354
- WHERE s.dataset = ? ORDER BY s.updated_at DESC LIMIT ?`).all(DEFAULT_DATASET, limit);
384
+ const rows = db.handle.prepare(`SELECT * FROM (
385
+ SELECT s.session_id, s.created_at, s.format_version, s.frame_count, s.last_seq, s.head_chain_hash,
386
+ s.revision, s.updated_at, s.deleted_at, s.purged_at, s.stored_bytes,
387
+ t.deleted_by, r.device_id AS reset_device_id, r.expires_at AS reset_expires_at, r.reset_at,
388
+ COALESCE(json_extract(s.manifest_json, '$.workspaceKey'), r.workspace_key) AS workspace_key
389
+ FROM sessions s
390
+ LEFT JOIN tombstones t
391
+ ON t.dataset = s.dataset AND t.session_id = s.session_id AND t.created_at = s.created_at
392
+ LEFT JOIN session_resets r
393
+ ON r.dataset = s.dataset AND r.session_id = s.session_id
394
+ WHERE s.dataset = ?
395
+ UNION ALL
396
+ SELECT r.session_id, r.created_at, 0, 0, 0, '', 0, r.reset_at, NULL, NULL, 0,
397
+ NULL, r.device_id, r.expires_at, r.reset_at, r.workspace_key
398
+ FROM session_resets r
399
+ WHERE r.dataset = ? AND NOT EXISTS (
400
+ SELECT 1 FROM sessions s WHERE s.dataset = r.dataset AND s.session_id = r.session_id
401
+ )
402
+ )
403
+ ORDER BY updated_at DESC LIMIT ?`).all(DEFAULT_DATASET, DEFAULT_DATASET, limit);
355
404
  return rows.map((row)=>({
356
405
  sessionId: row.session_id,
357
406
  createdAt: row.created_at,
@@ -362,6 +411,12 @@ export function adminSessions(db, limit = 500) {
362
411
  revision: row.revision,
363
412
  updatedAt: row.updated_at,
364
413
  deletedAt: row.deleted_at,
414
+ deletedBy: row.deleted_by,
415
+ purgedAt: row.purged_at,
416
+ resetAt: row.reset_at,
417
+ resetDeviceId: row.reset_device_id,
418
+ resetExpiresAt: row.reset_expires_at,
419
+ storedBytes: row.stored_bytes,
365
420
  workspaceKey: row.workspace_key === null ? null : String(row.workspace_key)
366
421
  }));
367
422
  }
@@ -372,31 +427,6 @@ export function sessionCounts(db) {
372
427
  deleted: Number(row?.deleted ?? 0)
373
428
  };
374
429
  }
375
- export function listChanges(db, since, limit) {
376
- 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);
377
- 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);
378
- const sessions = rows.slice(0, limit).map((row)=>({
379
- sessionId: row.session_id,
380
- revision: row.revision,
381
- formatVersion: row.format_version,
382
- createdAt: row.created_at,
383
- batchCount: row.frame_count,
384
- headChainHash: row.head_chain_hash,
385
- manifestDigest: row.manifest_digest,
386
- updatedAt: row.updated_at,
387
- deletedAt: row.deleted_at
388
- }));
389
- return {
390
- revision: readRevision(db),
391
- sessions,
392
- tombstones: tombstones.slice(0, limit).map((row)=>({
393
- sessionId: row.session_id,
394
- createdAt: row.created_at,
395
- deletedAt: row.deleted_at
396
- })),
397
- hasMore: rows.length > limit || tombstones.length > limit
398
- };
399
- }
400
430
 
401
431
 
402
432
  //# sourceURL=sessions.ts