dscs-server 0.0.1 → 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/README.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  DSH Session CloudSync 的自部署服务端。
4
4
 
5
+ 当前版本:**0.1.0,v2-only**(`PROTOCOL_VERSION = 3`)。设备 API 只有 `/v2/*`,
6
+ 旧的 `/v1/*` 已移除;0.0.x 插件不兼容,需要配套的 0.1.0 插件。
7
+
8
+ ## 能力
9
+
10
+ - `/v2/sessions` 全量快照、`/v2/changes` 增量流;
11
+ - 会话摘要、ETag、批次条件分页;
12
+ - 带 `Idempotency-Key` 的 append;
13
+ - `POST /v2/blobs/batch` / `POST /v2/blobs/batch/read` 批量 blob(8 MiB / 64 条);
14
+ - zstd 封帧(加密前压缩)与服务端压缩状态聚合;
15
+ - Vault 租约、append、cursor、stream。
16
+
5
17
  ## 安装
6
18
 
7
19
  ```sh
@@ -19,6 +31,8 @@ Linux/macOS 也可以使用仓库的 `scripts/install.sh`;Windows 使用 `scri
19
31
  - 默认只监听 `127.0.0.1`,公网部署请放在 TLS 反代后;
20
32
  - 数据目录包含 SQLite 与 blob 文件,升级前请备份;
21
33
  - 服务端不持有明文会话内容,只存不透明密文;
34
+ - 0.0.1 数据目录升级到 0.1.0 会迁移到 schema 4,但旧插件仍会 404;
35
+ - 已有未压缩会话不会自动压缩;需要清空数据目录后由 0.1.0 插件全量重传;
22
36
  - 协议说明见仓库 `docs/architecture/PROTOCOL.md`。
23
37
 
24
38
  ## 许可证
package/dist/db.js CHANGED
@@ -3,7 +3,7 @@ import { mkdirSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { DatabaseSync } from 'node:sqlite';
5
5
  export const DEFAULT_DATASET = 'default';
6
- export const SERVER_SCHEMA_VERSION = 3;
6
+ export const SERVER_SCHEMA_VERSION = 4;
7
7
  const SCHEMA_VERSION = SERVER_SCHEMA_VERSION;
8
8
  const SCHEMA = `
9
9
  CREATE TABLE IF NOT EXISTS meta (
@@ -35,6 +35,8 @@ CREATE TABLE IF NOT EXISTS sessions (
35
35
  revision INTEGER NOT NULL,
36
36
  updated_at INTEGER NOT NULL,
37
37
  deleted_at INTEGER,
38
+ compression TEXT NOT NULL DEFAULT 'none',
39
+ stored_bytes INTEGER NOT NULL DEFAULT 0,
38
40
  PRIMARY KEY (dataset, session_id)
39
41
  );
40
42
 
@@ -45,6 +47,17 @@ CREATE TABLE IF NOT EXISTS blobs (
45
47
  created_at INTEGER NOT NULL
46
48
  );
47
49
 
50
+ CREATE TABLE IF NOT EXISTS append_requests (
51
+ dataset TEXT NOT NULL,
52
+ device_id TEXT NOT NULL,
53
+ session_id TEXT NOT NULL,
54
+ idempotency_key TEXT NOT NULL,
55
+ request_hash TEXT NOT NULL,
56
+ result_json TEXT NOT NULL,
57
+ created_at INTEGER NOT NULL,
58
+ PRIMARY KEY (dataset, device_id, session_id, idempotency_key)
59
+ );
60
+
48
61
  CREATE TABLE IF NOT EXISTS leases (
49
62
  dataset TEXT NOT NULL,
50
63
  resource TEXT NOT NULL,
@@ -113,12 +126,59 @@ function backupBeforeMigration(handle, dataDir, version) {
113
126
  const target = join(dataDir, `db.sqlite.bak-v${version}-${Date.now()}`);
114
127
  handle.exec(`VACUUM INTO '${target.replaceAll("'", "''")}'`);
115
128
  }
129
+ function backfillSessionStats(handle) {
130
+ const rows = handle.prepare('SELECT session_id, manifest_json FROM sessions').all();
131
+ const update = handle.prepare('UPDATE sessions SET compression = ?, stored_bytes = ? WHERE dataset = ? AND session_id = ?');
132
+ for (const row of rows){
133
+ let compression = 'none';
134
+ let storedBytes = 0;
135
+ try {
136
+ const manifest = JSON.parse(row.manifest_json);
137
+ const frames = Array.isArray(manifest.frames) ? manifest.frames : [];
138
+ let compressed = 0;
139
+ for (const frame of frames){
140
+ if (frame === null || typeof frame !== 'object') continue;
141
+ const record = frame;
142
+ if (record['compression'] === 'zstd') compressed += 1;
143
+ if (typeof record['bytes'] === 'number' && Number.isSafeInteger(record['bytes'])) storedBytes += record['bytes'];
144
+ }
145
+ compression = compressed === 0 ? 'none' : compressed === frames.length ? 'zstd' : 'mixed';
146
+ } catch {}
147
+ update.run(compression, storedBytes, DEFAULT_DATASET, row.session_id);
148
+ }
149
+ }
150
+ function ensureSessionStatsColumns(handle) {
151
+ const columns = tableColumns(handle, 'sessions');
152
+ let added = false;
153
+ if (!columns.has('compression')) {
154
+ handle.exec("ALTER TABLE sessions ADD COLUMN compression TEXT NOT NULL DEFAULT 'none'");
155
+ added = true;
156
+ }
157
+ if (!columns.has('stored_bytes')) {
158
+ handle.exec('ALTER TABLE sessions ADD COLUMN stored_bytes INTEGER NOT NULL DEFAULT 0');
159
+ added = true;
160
+ }
161
+ if (added) backfillSessionStats(handle);
162
+ return added;
163
+ }
116
164
  function migrateDatabase(handle, dataDir, version) {
117
165
  if (version > SCHEMA_VERSION) {
118
166
  throw new Error(`数据库结构版本 ${version} 高于服务端支持的 ${SCHEMA_VERSION};请升级服务端,不要降级覆盖。`);
119
167
  }
120
168
  if (version === SCHEMA_VERSION) {
121
169
  handle.exec(SCHEMA);
170
+ const columns = tableColumns(handle, 'sessions');
171
+ if (!columns.has('compression') || !columns.has('stored_bytes')) {
172
+ backupBeforeMigration(handle, dataDir, version);
173
+ handle.exec('BEGIN IMMEDIATE');
174
+ try {
175
+ ensureSessionStatsColumns(handle);
176
+ handle.exec('COMMIT');
177
+ } catch (error) {
178
+ handle.exec('ROLLBACK');
179
+ throw error;
180
+ }
181
+ }
122
182
  return;
123
183
  }
124
184
  let sourceVersion = version;
@@ -150,6 +210,7 @@ function migrateDatabase(handle, dataDir, version) {
150
210
  handle.exec('DROP TABLE IF EXISTS leases');
151
211
  }
152
212
  handle.exec(SCHEMA);
213
+ ensureSessionStatsColumns(handle);
153
214
  handle.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
154
215
  handle.exec('COMMIT');
155
216
  } catch (error) {
@@ -225,6 +286,16 @@ export function readMaxBytes(db, fallback) {
225
286
  export function writeMaxBytes(db, maxBytes) {
226
287
  writeMeta(db, 'max_bytes', String(maxBytes));
227
288
  }
289
+ export function readAppendRequest(db, deviceId, sessionId, key) {
290
+ const row = db.handle.prepare('SELECT request_hash, result_json FROM append_requests WHERE dataset = ? AND device_id = ? AND session_id = ? AND idempotency_key = ?').get(DEFAULT_DATASET, deviceId, sessionId, key);
291
+ return row === undefined ? undefined : {
292
+ requestHash: String(row.request_hash),
293
+ resultJson: String(row.result_json)
294
+ };
295
+ }
296
+ export function writeAppendRequest(db, deviceId, sessionId, key, requestHash, resultJson, createdAt) {
297
+ db.handle.prepare('INSERT INTO append_requests (dataset, device_id, session_id, idempotency_key, request_hash, result_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(DEFAULT_DATASET, deviceId, sessionId, key, requestHash, resultJson, createdAt);
298
+ }
228
299
  export function readRevision(db) {
229
300
  return Number(readMeta(db, 'revision') ?? '0');
230
301
  }