dscs-server 0.1.3 → 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/README.md +57 -17
- package/dist/admin-password.js +27 -0
- package/dist/audit.js +73 -0
- package/dist/db.js +174 -3
- package/dist/http.js +428 -81
- package/dist/index.js +23 -7
- package/dist/session-admin.js +200 -0
- package/dist/session-history.js +134 -0
- package/dist/sessions.js +103 -73
- package/dist/shared/wire.js +1 -0
- package/package.json +1 -1
- package/web/admin.html +1833 -297
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 {
|
|
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
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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,
|
|
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
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|