dscs-server 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/sessions.js CHANGED
@@ -3,11 +3,13 @@ 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, blobRecord, 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;
12
+ export const MAX_FRAME_ATTACHMENT_BLOBS = 256;
11
13
  function assertRef(frame, expectedIndex) {
12
14
  if (frame.index !== expectedIndex) throw new SyncError('bad-request', `帧下标应为 ${expectedIndex},实际是 ${frame.index}`);
13
15
  if (!HASH.test(frame.chainHash) || !HASH.test(frame.blobSha256)) throw new SyncError('bad-request', `帧 ${expectedIndex} 的哈希非法`);
@@ -27,6 +29,27 @@ function assertRef(frame, expectedIndex) {
27
29
  }
28
30
  }
29
31
  if (!Number.isSafeInteger(frame.seqFrom) || !Number.isSafeInteger(frame.seqTo)) throw new SyncError('bad-request', `帧 ${expectedIndex} 的 seq 非法`);
32
+ if (frame.attachmentIndexSha256 !== undefined && !HASH.test(frame.attachmentIndexSha256)) {
33
+ throw new SyncError('bad-request', `帧 ${expectedIndex} 的附件索引哈希非法`);
34
+ }
35
+ const attachments = frame.attachmentBlobs;
36
+ if (attachments !== undefined) {
37
+ if (!Array.isArray(attachments) || attachments.length < 1 || attachments.length > MAX_FRAME_ATTACHMENT_BLOBS) {
38
+ throw new SyncError('bad-request', `帧 ${expectedIndex} 的附件列表必须是 1–${MAX_FRAME_ATTACHMENT_BLOBS} 个哈希`);
39
+ }
40
+ const seen = new Set();
41
+ for (const sha of attachments){
42
+ if (typeof sha !== 'string' || !HASH.test(sha)) throw new SyncError('bad-request', `帧 ${expectedIndex} 的附件哈希非法`);
43
+ if (seen.has(sha)) throw new SyncError('bad-request', `帧 ${expectedIndex} 的附件列表重复:${sha}`);
44
+ seen.add(sha);
45
+ }
46
+ }
47
+ }
48
+ function requireStoredBlob(db, dataDir, sha256, kind, label) {
49
+ if (!existsSync(blobPath(dataDir, sha256))) throw new SyncError('bad-request', `${label} ${sha256} 尚未上传`);
50
+ const record = blobRecord(db, sha256);
51
+ if (record === undefined) throw new SyncError('bad-request', `${label} ${sha256} 没有登记`);
52
+ if (record.kind !== kind) throw new SyncError('bad-request', `${label} ${sha256} 的类别是 ${record.kind},应为 ${kind}`);
30
53
  }
31
54
  function toChain(frames) {
32
55
  return frames.map((frame)=>({
@@ -37,7 +60,18 @@ function toChain(frames) {
37
60
  }));
38
61
  }
39
62
  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);
63
+ 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);
64
+ }
65
+ function activeReset(db, sessionId, now) {
66
+ const row = db.handle.prepare('SELECT device_id, expires_at FROM session_resets WHERE dataset = ? AND session_id = ?').get(DEFAULT_DATASET, sessionId);
67
+ if (row === undefined) return undefined;
68
+ if (row.expires_at <= now) {
69
+ db.handle.prepare('DELETE FROM session_resets WHERE dataset = ? AND session_id = ?').run(DEFAULT_DATASET, sessionId);
70
+ return undefined;
71
+ }
72
+ return {
73
+ device_id: row.device_id
74
+ };
41
75
  }
42
76
  function manifestOf(sessionId, formatVersion, createdAt, updatedAt, frames, tailOpen, safeThroughSeq, workspaceKey, parentSession, origin, delegationDepth) {
43
77
  assertChain(toChain(frames));
@@ -105,6 +139,10 @@ function appendRequestHash(input) {
105
139
  }
106
140
  export function appendFrames(db, dataDir, input, now = Date.now()) {
107
141
  return withTransaction(db, ()=>{
142
+ const reset = activeReset(db, input.sessionId, now);
143
+ if (reset !== undefined && reset.device_id !== null && reset.device_id !== input.deviceId) {
144
+ throw new SyncError('reset-pending', '该会话正在等待指定设备重建');
145
+ }
108
146
  const idempotencyKey = input.idempotencyKey;
109
147
  const requestHash = idempotencyKey === undefined ? undefined : appendRequestHash(input);
110
148
  if (idempotencyKey !== undefined && requestHash !== undefined) {
@@ -125,6 +163,9 @@ export function appendFrames(db, dataDir, input, now = Date.now()) {
125
163
  let parentSession = input.parentSession;
126
164
  let origin = input.origin;
127
165
  let delegationDepth = input.delegationDepth;
166
+ if (row !== undefined && row.purged_at !== null) {
167
+ throw new SyncError('gone', `会话 ${input.sessionId} 已移除并释放空间,需要由管理台重置后才能重建`);
168
+ }
128
169
  if (row !== undefined && row.deleted_at !== null && row.created_at === input.createdAt) {
129
170
  throw new SyncError('gone', `会话 ${input.sessionId} 的这个生命周期已被删除`);
130
171
  }
@@ -180,16 +221,24 @@ export function appendFrames(db, dataDir, input, now = Date.now()) {
180
221
  if (!existsSync(blobPath(dataDir, frame.blobSha256))) throw new SyncError('bad-request', `帧 ${frame.index} 的 blob 尚未上传`);
181
222
  }
182
223
  input.frames.forEach((frame, offset)=>assertRef(frame, (row === undefined || replacingLifecycle ? 0 : input.baseIndex + 1) + offset));
224
+ for (const frame of input.frames){
225
+ if (frame.attachmentIndexSha256 !== undefined) {
226
+ requireStoredBlob(db, dataDir, frame.attachmentIndexSha256, 'attachment-index', `帧 ${frame.index} 的附件索引`);
227
+ }
228
+ for (const sha of frame.attachmentBlobs ?? []){
229
+ requireStoredBlob(db, dataDir, sha, 'attachment', `帧 ${frame.index} 的附件`);
230
+ }
231
+ }
183
232
  const { manifest, digest, headChainHash } = manifestOf(input.sessionId, formatVersion, createdAt, now, combined, input.tailOpen, input.safeThroughSeq, workspaceKey, parentSession, origin, delegationDepth);
184
233
  const revision = bumpRevision(db);
185
234
  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, ?, ?)
235
+ 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)
236
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)
188
237
  ON CONFLICT(dataset, session_id) DO UPDATE SET
189
238
  created_at = excluded.created_at, format_version = excluded.format_version, last_seq = excluded.last_seq,
190
239
  frame_count = excluded.frame_count, head_chain_hash = excluded.head_chain_hash,
191
240
  manifest_json = excluded.manifest_json, manifest_digest = excluded.manifest_digest,
192
- revision = excluded.revision, updated_at = excluded.updated_at, deleted_at = NULL,
241
+ revision = excluded.revision, updated_at = excluded.updated_at, deleted_at = NULL, purged_at = NULL,
193
242
  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
243
  const summary = {
195
244
  sessionId: input.sessionId,
@@ -206,6 +255,37 @@ export function appendFrames(db, dataDir, input, now = Date.now()) {
206
255
  if (idempotencyKey !== undefined && requestHash !== undefined) {
207
256
  writeAppendRequest(db, input.deviceId, input.sessionId, idempotencyKey, requestHash, JSON.stringify(summary), now);
208
257
  }
258
+ if (reset !== undefined) {
259
+ db.handle.prepare('DELETE FROM session_resets WHERE dataset = ? AND session_id = ?').run(DEFAULT_DATASET, input.sessionId);
260
+ }
261
+ const created = row === undefined || replacingLifecycle;
262
+ const oldSeq = created ? 0 : row.last_seq;
263
+ const oldBytes = created ? 0 : row.stored_bytes;
264
+ const tailReplaced = !created && input.baseIndex < row.frame_count - 1;
265
+ recordSessionEvent(db, {
266
+ sessionId: input.sessionId,
267
+ at: now,
268
+ kind: created ? 'create' : 'append',
269
+ deviceId: input.deviceId,
270
+ actorKind: 'device',
271
+ actorId: input.deviceId,
272
+ events: tailReplaced ? 0 : Math.max(0, summary.lastSeq - oldSeq),
273
+ bytes: Math.max(0, summary.storedBytes - oldBytes),
274
+ seqFrom: input.frames[0]?.seqFrom ?? null,
275
+ seqTo: input.frames[input.frames.length - 1]?.seqTo ?? null,
276
+ batchCount: input.frames.length,
277
+ lastSeq: summary.lastSeq,
278
+ storedBytes: summary.storedBytes,
279
+ detail: {
280
+ createdAt,
281
+ ...tailReplaced ? {
282
+ tailReplaced: true
283
+ } : {},
284
+ ...workspaceKey === undefined ? {} : {
285
+ workspaceKey
286
+ }
287
+ }
288
+ });
209
289
  return summary;
210
290
  });
211
291
  }
@@ -219,6 +299,19 @@ export function deleteSession(db, sessionId, deviceId, clientId, createdAt, now
219
299
  const revision = bumpRevision(db);
220
300
  db.handle.prepare('UPDATE sessions SET deleted_at = ?, revision = ?, updated_at = ? WHERE dataset = ? AND session_id = ?').run(now, revision, now, DEFAULT_DATASET, sessionId);
221
301
  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);
302
+ recordSessionEvent(db, {
303
+ sessionId,
304
+ at: now,
305
+ kind: 'device-delete',
306
+ deviceId,
307
+ actorKind: 'device',
308
+ actorId: deviceId,
309
+ lastSeq: row.last_seq,
310
+ storedBytes: row.stored_bytes,
311
+ detail: {
312
+ createdAt
313
+ }
314
+ });
222
315
  return {
223
316
  sessionId,
224
317
  revision,
@@ -253,7 +346,10 @@ export function listBatches(db, sessionId, from, limit) {
253
346
  }
254
347
  const V2_SUMMARY_SELECT = `s.session_id, s.created_at, s.format_version, s.last_seq, s.frame_count,
255
348
  s.head_chain_hash, s.manifest_digest, s.revision, s.updated_at, s.deleted_at,
256
- s.compression, s.stored_bytes,
349
+ s.purged_at, s.compression, s.stored_bytes,
350
+ (SELECT t.deleted_by FROM tombstones t
351
+ WHERE t.dataset = s.dataset AND t.session_id = s.session_id AND t.created_at = s.created_at
352
+ LIMIT 1) AS deleted_by,
257
353
  json_extract(s.manifest_json, '$.workspaceKey') AS workspace_key,
258
354
  json_extract(s.manifest_json, '$.tailOpen') AS tail_open,
259
355
  json_extract(s.manifest_json, '$.safeThroughSeq') AS safe_through_seq,
@@ -279,15 +375,17 @@ function mapV2Summary(row) {
279
375
  delegationDepth: typeof row.delegation_depth === 'number' && Number.isSafeInteger(row.delegation_depth) ? row.delegation_depth : null,
280
376
  updatedAt: row.updated_at,
281
377
  deletedAt: row.deleted_at,
378
+ deletedBy: row.deleted_by,
379
+ purgedAt: row.purged_at,
282
380
  compression: compressionOf(row.compression),
283
381
  storedBytes: Number.isSafeInteger(row.stored_bytes) ? row.stored_bytes : 0
284
382
  };
285
383
  }
286
- export function listV2Sessions(db, after, limit) {
384
+ export function listV2Sessions(db, after, limit, includeDeleted = false) {
287
385
  const rows = db.handle.prepare(`SELECT ${V2_SUMMARY_SELECT}
288
386
  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);
387
+ WHERE s.dataset = ? AND (? = 1 OR s.deleted_at IS NULL) AND s.session_id > ?
388
+ ORDER BY s.session_id ASC LIMIT ?`).all(DEFAULT_DATASET, includeDeleted ? 1 : 0, after ?? '', limit + 1);
291
389
  const visible = rows.slice(0, limit);
292
390
  const last = visible[visible.length - 1];
293
391
  return {
@@ -295,40 +393,6 @@ export function listV2Sessions(db, after, limit) {
295
393
  nextSessionId: rows.length > limit && last !== undefined ? last.session_id : null
296
394
  };
297
395
  }
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
396
  export function getV2SessionSummary(db, sessionId) {
333
397
  const row = db.handle.prepare(`SELECT ${V2_SUMMARY_SELECT}
334
398
  FROM sessions s
@@ -347,11 +411,26 @@ export function listV2Batches(db, sessionId, from, limit) {
347
411
  };
348
412
  }
349
413
  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);
414
+ const rows = db.handle.prepare(`SELECT * FROM (
415
+ SELECT s.session_id, s.created_at, s.format_version, s.frame_count, s.last_seq, s.head_chain_hash,
416
+ s.revision, s.updated_at, s.deleted_at, s.purged_at, s.stored_bytes,
417
+ t.deleted_by, r.device_id AS reset_device_id, r.expires_at AS reset_expires_at, r.reset_at,
418
+ COALESCE(json_extract(s.manifest_json, '$.workspaceKey'), r.workspace_key) AS workspace_key
419
+ FROM sessions s
420
+ LEFT JOIN tombstones t
421
+ ON t.dataset = s.dataset AND t.session_id = s.session_id AND t.created_at = s.created_at
422
+ LEFT JOIN session_resets r
423
+ ON r.dataset = s.dataset AND r.session_id = s.session_id
424
+ WHERE s.dataset = ?
425
+ UNION ALL
426
+ SELECT r.session_id, r.created_at, 0, 0, 0, '', 0, r.reset_at, NULL, NULL, 0,
427
+ NULL, r.device_id, r.expires_at, r.reset_at, r.workspace_key
428
+ FROM session_resets r
429
+ WHERE r.dataset = ? AND NOT EXISTS (
430
+ SELECT 1 FROM sessions s WHERE s.dataset = r.dataset AND s.session_id = r.session_id
431
+ )
432
+ )
433
+ ORDER BY updated_at DESC LIMIT ?`).all(DEFAULT_DATASET, DEFAULT_DATASET, limit);
355
434
  return rows.map((row)=>({
356
435
  sessionId: row.session_id,
357
436
  createdAt: row.created_at,
@@ -362,6 +441,12 @@ export function adminSessions(db, limit = 500) {
362
441
  revision: row.revision,
363
442
  updatedAt: row.updated_at,
364
443
  deletedAt: row.deleted_at,
444
+ deletedBy: row.deleted_by,
445
+ purgedAt: row.purged_at,
446
+ resetAt: row.reset_at,
447
+ resetDeviceId: row.reset_device_id,
448
+ resetExpiresAt: row.reset_expires_at,
449
+ storedBytes: row.stored_bytes,
365
450
  workspaceKey: row.workspace_key === null ? null : String(row.workspace_key)
366
451
  }));
367
452
  }
@@ -372,31 +457,6 @@ export function sessionCounts(db) {
372
457
  deleted: Number(row?.deleted ?? 0)
373
458
  };
374
459
  }
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
460
 
401
461
 
402
462
  //# sourceURL=sessions.ts
@@ -10,12 +10,14 @@ const VERSION = 1;
10
10
  const KIND_TO_CODE = {
11
11
  frame: 1,
12
12
  attachment: 2,
13
- vault: 3
13
+ vault: 3,
14
+ 'attachment-index': 4
14
15
  };
15
16
  const CODE_TO_KIND = {
16
17
  1: 'frame',
17
18
  2: 'attachment',
18
- 3: 'vault'
19
+ 3: 'vault',
20
+ 4: 'attachment-index'
19
21
  };
20
22
  export function encodeBlobBatch(entries) {
21
23
  if (entries.length < 1 || entries.length > MAX_BATCH_BLOBS) {
@@ -5,6 +5,7 @@ export const ERROR_STATUS = {
5
5
  conflict: 409,
6
6
  diverged: 409,
7
7
  'lease-held': 409,
8
+ 'reset-pending': 409,
8
9
  gone: 410,
9
10
  quota: 507,
10
11
  'rate-limited': 429
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dscs-server",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "DSH Session CloudSync self-hosted server.",