session-collab-mcp 0.3.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/bin/session-collab-mcp +9 -0
- package/migrations/0001_init.sql +72 -0
- package/migrations/0002_auth.sql +56 -0
- package/migrations/0002_session_status.sql +10 -0
- package/package.json +39 -0
- package/src/auth/handlers.ts +326 -0
- package/src/auth/jwt.ts +112 -0
- package/src/auth/middleware.ts +144 -0
- package/src/auth/password.ts +84 -0
- package/src/auth/types.ts +70 -0
- package/src/cli.ts +140 -0
- package/src/db/auth-queries.ts +256 -0
- package/src/db/queries.ts +562 -0
- package/src/db/sqlite-adapter.ts +137 -0
- package/src/db/types.ts +137 -0
- package/src/frontend/app.ts +1181 -0
- package/src/index.ts +438 -0
- package/src/mcp/protocol.ts +99 -0
- package/src/mcp/server.ts +155 -0
- package/src/mcp/tools/claim.ts +358 -0
- package/src/mcp/tools/decision.ts +124 -0
- package/src/mcp/tools/message.ts +150 -0
- package/src/mcp/tools/session.ts +322 -0
- package/src/tokens/generator.ts +33 -0
- package/src/tokens/handlers.ts +113 -0
- package/src/utils/crypto.ts +82 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
// Database queries for Session Collaboration MCP
|
|
2
|
+
|
|
3
|
+
import type { D1Database } from '@cloudflare/workers-types';
|
|
4
|
+
import type {
|
|
5
|
+
Session,
|
|
6
|
+
Claim,
|
|
7
|
+
ClaimStatus,
|
|
8
|
+
ClaimScope,
|
|
9
|
+
ClaimWithFiles,
|
|
10
|
+
ConflictInfo,
|
|
11
|
+
Message,
|
|
12
|
+
Decision,
|
|
13
|
+
DecisionCategory,
|
|
14
|
+
TodoItem,
|
|
15
|
+
SessionProgress,
|
|
16
|
+
} from './types';
|
|
17
|
+
|
|
18
|
+
// Helper to generate UUID v4
|
|
19
|
+
function generateId(): string {
|
|
20
|
+
return crypto.randomUUID();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ============ Session Queries ============
|
|
24
|
+
|
|
25
|
+
export async function createSession(
|
|
26
|
+
db: D1Database,
|
|
27
|
+
params: {
|
|
28
|
+
name?: string;
|
|
29
|
+
project_root: string;
|
|
30
|
+
machine_id?: string;
|
|
31
|
+
user_id?: string;
|
|
32
|
+
}
|
|
33
|
+
): Promise<Session> {
|
|
34
|
+
const id = generateId();
|
|
35
|
+
const now = new Date().toISOString();
|
|
36
|
+
|
|
37
|
+
await db
|
|
38
|
+
.prepare(
|
|
39
|
+
`INSERT INTO sessions (id, name, project_root, machine_id, user_id, created_at, last_heartbeat, status)
|
|
40
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'active')`
|
|
41
|
+
)
|
|
42
|
+
.bind(id, params.name ?? null, params.project_root, params.machine_id ?? null, params.user_id ?? null, now, now)
|
|
43
|
+
.run();
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
id,
|
|
47
|
+
name: params.name ?? null,
|
|
48
|
+
project_root: params.project_root,
|
|
49
|
+
machine_id: params.machine_id ?? null,
|
|
50
|
+
user_id: params.user_id ?? null,
|
|
51
|
+
created_at: now,
|
|
52
|
+
last_heartbeat: now,
|
|
53
|
+
status: 'active',
|
|
54
|
+
current_task: null,
|
|
55
|
+
progress: null,
|
|
56
|
+
todos: null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function getSession(db: D1Database, id: string): Promise<Session | null> {
|
|
61
|
+
const result = await db.prepare('SELECT * FROM sessions WHERE id = ?').bind(id).first<Session>();
|
|
62
|
+
return result ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function listSessions(
|
|
66
|
+
db: D1Database,
|
|
67
|
+
params: {
|
|
68
|
+
include_inactive?: boolean;
|
|
69
|
+
project_root?: string;
|
|
70
|
+
user_id?: string;
|
|
71
|
+
} = {}
|
|
72
|
+
): Promise<Session[]> {
|
|
73
|
+
let query = 'SELECT * FROM sessions WHERE 1=1';
|
|
74
|
+
const bindings: (string | number)[] = [];
|
|
75
|
+
|
|
76
|
+
if (!params.include_inactive) {
|
|
77
|
+
query += " AND status = 'active'";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (params.project_root) {
|
|
81
|
+
query += ' AND project_root = ?';
|
|
82
|
+
bindings.push(params.project_root);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (params.user_id) {
|
|
86
|
+
query += ' AND user_id = ?';
|
|
87
|
+
bindings.push(params.user_id);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
query += ' ORDER BY last_heartbeat DESC';
|
|
91
|
+
|
|
92
|
+
const result = await db
|
|
93
|
+
.prepare(query)
|
|
94
|
+
.bind(...bindings)
|
|
95
|
+
.all<Session>();
|
|
96
|
+
return result.results;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function updateSessionHeartbeat(
|
|
100
|
+
db: D1Database,
|
|
101
|
+
id: string,
|
|
102
|
+
statusUpdate?: {
|
|
103
|
+
current_task?: string | null;
|
|
104
|
+
todos?: TodoItem[];
|
|
105
|
+
}
|
|
106
|
+
): Promise<boolean> {
|
|
107
|
+
const now = new Date().toISOString();
|
|
108
|
+
|
|
109
|
+
// Calculate progress from todos if provided
|
|
110
|
+
let progress: SessionProgress | null = null;
|
|
111
|
+
let todosJson: string | null = null;
|
|
112
|
+
|
|
113
|
+
if (statusUpdate?.todos) {
|
|
114
|
+
const total = statusUpdate.todos.length;
|
|
115
|
+
const completed = statusUpdate.todos.filter((t) => t.status === 'completed').length;
|
|
116
|
+
progress = {
|
|
117
|
+
completed,
|
|
118
|
+
total,
|
|
119
|
+
percentage: total > 0 ? Math.round((completed / total) * 100) : 0,
|
|
120
|
+
};
|
|
121
|
+
todosJson = JSON.stringify(statusUpdate.todos);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let query = "UPDATE sessions SET last_heartbeat = ?";
|
|
125
|
+
const bindings: (string | null)[] = [now];
|
|
126
|
+
|
|
127
|
+
if (statusUpdate?.current_task !== undefined) {
|
|
128
|
+
query += ", current_task = ?";
|
|
129
|
+
bindings.push(statusUpdate.current_task);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (progress) {
|
|
133
|
+
query += ", progress = ?";
|
|
134
|
+
bindings.push(JSON.stringify(progress));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (todosJson) {
|
|
138
|
+
query += ", todos = ?";
|
|
139
|
+
bindings.push(todosJson);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
query += " WHERE id = ? AND status = 'active'";
|
|
143
|
+
bindings.push(id);
|
|
144
|
+
|
|
145
|
+
const result = await db
|
|
146
|
+
.prepare(query)
|
|
147
|
+
.bind(...bindings)
|
|
148
|
+
.run();
|
|
149
|
+
return result.meta.changes > 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function updateSessionStatus(
|
|
153
|
+
db: D1Database,
|
|
154
|
+
id: string,
|
|
155
|
+
params: {
|
|
156
|
+
current_task?: string | null;
|
|
157
|
+
todos?: TodoItem[];
|
|
158
|
+
}
|
|
159
|
+
): Promise<boolean> {
|
|
160
|
+
const now = new Date().toISOString();
|
|
161
|
+
|
|
162
|
+
// Calculate progress from todos
|
|
163
|
+
let progress: SessionProgress | null = null;
|
|
164
|
+
let todosJson: string | null = null;
|
|
165
|
+
|
|
166
|
+
if (params.todos) {
|
|
167
|
+
const total = params.todos.length;
|
|
168
|
+
const completed = params.todos.filter((t) => t.status === 'completed').length;
|
|
169
|
+
progress = {
|
|
170
|
+
completed,
|
|
171
|
+
total,
|
|
172
|
+
percentage: total > 0 ? Math.round((completed / total) * 100) : 0,
|
|
173
|
+
};
|
|
174
|
+
todosJson = JSON.stringify(params.todos);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const result = await db
|
|
178
|
+
.prepare(
|
|
179
|
+
`UPDATE sessions
|
|
180
|
+
SET current_task = ?, progress = ?, todos = ?, last_heartbeat = ?
|
|
181
|
+
WHERE id = ? AND status = 'active'`
|
|
182
|
+
)
|
|
183
|
+
.bind(
|
|
184
|
+
params.current_task ?? null,
|
|
185
|
+
progress ? JSON.stringify(progress) : null,
|
|
186
|
+
todosJson,
|
|
187
|
+
now,
|
|
188
|
+
id
|
|
189
|
+
)
|
|
190
|
+
.run();
|
|
191
|
+
|
|
192
|
+
return result.meta.changes > 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function endSession(
|
|
196
|
+
db: D1Database,
|
|
197
|
+
id: string,
|
|
198
|
+
release_claims: 'complete' | 'abandon' = 'abandon'
|
|
199
|
+
): Promise<boolean> {
|
|
200
|
+
const claimStatus: ClaimStatus = release_claims === 'complete' ? 'completed' : 'abandoned';
|
|
201
|
+
|
|
202
|
+
// Update all active claims for this session
|
|
203
|
+
await db
|
|
204
|
+
.prepare("UPDATE claims SET status = ?, updated_at = ? WHERE session_id = ? AND status = 'active'")
|
|
205
|
+
.bind(claimStatus, new Date().toISOString(), id)
|
|
206
|
+
.run();
|
|
207
|
+
|
|
208
|
+
// Mark session as terminated
|
|
209
|
+
const result = await db.prepare("UPDATE sessions SET status = 'terminated' WHERE id = ?").bind(id).run();
|
|
210
|
+
|
|
211
|
+
return result.meta.changes > 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function cleanupStaleSessions(db: D1Database, staleMinutes: number = 30): Promise<{ stale_sessions: number; orphaned_claims: number }> {
|
|
215
|
+
const cutoff = new Date(Date.now() - staleMinutes * 60 * 1000).toISOString();
|
|
216
|
+
const now = new Date().toISOString();
|
|
217
|
+
|
|
218
|
+
// Mark stale sessions as inactive
|
|
219
|
+
const result = await db
|
|
220
|
+
.prepare("UPDATE sessions SET status = 'inactive' WHERE status = 'active' AND last_heartbeat < ?")
|
|
221
|
+
.bind(cutoff)
|
|
222
|
+
.run();
|
|
223
|
+
|
|
224
|
+
// Abandon claims from inactive/terminated sessions
|
|
225
|
+
const orphanedResult = await db
|
|
226
|
+
.prepare(
|
|
227
|
+
`UPDATE claims SET status = 'abandoned', updated_at = ?
|
|
228
|
+
WHERE status = 'active' AND session_id IN (
|
|
229
|
+
SELECT id FROM sessions WHERE status IN ('inactive', 'terminated')
|
|
230
|
+
)`
|
|
231
|
+
)
|
|
232
|
+
.bind(now)
|
|
233
|
+
.run();
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
stale_sessions: result.meta.changes,
|
|
237
|
+
orphaned_claims: orphanedResult.meta.changes,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ============ Claim Queries ============
|
|
242
|
+
|
|
243
|
+
export async function createClaim(
|
|
244
|
+
db: D1Database,
|
|
245
|
+
params: {
|
|
246
|
+
session_id: string;
|
|
247
|
+
files: string[];
|
|
248
|
+
intent: string;
|
|
249
|
+
scope?: ClaimScope;
|
|
250
|
+
}
|
|
251
|
+
): Promise<{ claim: Claim; files: string[] }> {
|
|
252
|
+
const id = generateId();
|
|
253
|
+
const now = new Date().toISOString();
|
|
254
|
+
const scope = params.scope ?? 'medium';
|
|
255
|
+
|
|
256
|
+
// Batch insert: claim + all file paths in single transaction
|
|
257
|
+
const claimStatement = db
|
|
258
|
+
.prepare(
|
|
259
|
+
`INSERT INTO claims (id, session_id, intent, scope, status, created_at, updated_at)
|
|
260
|
+
VALUES (?, ?, ?, ?, 'active', ?, ?)`
|
|
261
|
+
)
|
|
262
|
+
.bind(id, params.session_id, params.intent, scope, now, now);
|
|
263
|
+
|
|
264
|
+
const fileStatements = params.files.map((filePath) => {
|
|
265
|
+
const isPattern = filePath.includes('*') ? 1 : 0;
|
|
266
|
+
return db
|
|
267
|
+
.prepare('INSERT INTO claim_files (claim_id, file_path, is_pattern) VALUES (?, ?, ?)')
|
|
268
|
+
.bind(id, filePath, isPattern);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await db.batch([claimStatement, ...fileStatements]);
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
claim: {
|
|
275
|
+
id,
|
|
276
|
+
session_id: params.session_id,
|
|
277
|
+
intent: params.intent,
|
|
278
|
+
scope,
|
|
279
|
+
status: 'active',
|
|
280
|
+
created_at: now,
|
|
281
|
+
updated_at: now,
|
|
282
|
+
completed_summary: null,
|
|
283
|
+
},
|
|
284
|
+
files: params.files,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export async function getClaim(db: D1Database, id: string): Promise<ClaimWithFiles | null> {
|
|
289
|
+
const claim = await db.prepare('SELECT * FROM claims WHERE id = ?').bind(id).first<Claim>();
|
|
290
|
+
|
|
291
|
+
if (!claim) return null;
|
|
292
|
+
|
|
293
|
+
const files = await db.prepare('SELECT file_path FROM claim_files WHERE claim_id = ?').bind(id).all<{ file_path: string }>();
|
|
294
|
+
|
|
295
|
+
const session = await db.prepare('SELECT name FROM sessions WHERE id = ?').bind(claim.session_id).first<{ name: string | null }>();
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
...claim,
|
|
299
|
+
files: files.results.map((f) => f.file_path),
|
|
300
|
+
session_name: session?.name ?? null,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function listClaims(
|
|
305
|
+
db: D1Database,
|
|
306
|
+
params: {
|
|
307
|
+
session_id?: string;
|
|
308
|
+
status?: ClaimStatus | 'all';
|
|
309
|
+
project_root?: string;
|
|
310
|
+
} = {}
|
|
311
|
+
): Promise<ClaimWithFiles[]> {
|
|
312
|
+
let query = `
|
|
313
|
+
SELECT c.*, s.name as session_name
|
|
314
|
+
FROM claims c
|
|
315
|
+
JOIN sessions s ON c.session_id = s.id
|
|
316
|
+
WHERE 1=1
|
|
317
|
+
`;
|
|
318
|
+
const bindings: string[] = [];
|
|
319
|
+
|
|
320
|
+
if (params.session_id) {
|
|
321
|
+
query += ' AND c.session_id = ?';
|
|
322
|
+
bindings.push(params.session_id);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (params.status && params.status !== 'all') {
|
|
326
|
+
query += ' AND c.status = ?';
|
|
327
|
+
bindings.push(params.status);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (params.project_root) {
|
|
331
|
+
query += ' AND s.project_root = ?';
|
|
332
|
+
bindings.push(params.project_root);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
query += ' ORDER BY c.created_at DESC';
|
|
336
|
+
|
|
337
|
+
const claims = await db
|
|
338
|
+
.prepare(query)
|
|
339
|
+
.bind(...bindings)
|
|
340
|
+
.all<Claim & { session_name: string | null }>();
|
|
341
|
+
|
|
342
|
+
if (claims.results.length === 0) {
|
|
343
|
+
return [];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Batch fetch all files for claims (avoid N+1 query)
|
|
347
|
+
const claimIds = claims.results.map((c) => c.id);
|
|
348
|
+
const placeholders = claimIds.map(() => '?').join(',');
|
|
349
|
+
const allFiles = await db
|
|
350
|
+
.prepare(`SELECT claim_id, file_path FROM claim_files WHERE claim_id IN (${placeholders})`)
|
|
351
|
+
.bind(...claimIds)
|
|
352
|
+
.all<{ claim_id: string; file_path: string }>();
|
|
353
|
+
|
|
354
|
+
// Group files by claim_id
|
|
355
|
+
const filesByClaimId = new Map<string, string[]>();
|
|
356
|
+
for (const f of allFiles.results) {
|
|
357
|
+
const arr = filesByClaimId.get(f.claim_id) ?? [];
|
|
358
|
+
arr.push(f.file_path);
|
|
359
|
+
filesByClaimId.set(f.claim_id, arr);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Assemble results
|
|
363
|
+
return claims.results.map((claim) => ({
|
|
364
|
+
...claim,
|
|
365
|
+
files: filesByClaimId.get(claim.id) ?? [],
|
|
366
|
+
}));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export async function checkConflicts(
|
|
370
|
+
db: D1Database,
|
|
371
|
+
files: string[],
|
|
372
|
+
excludeSessionId?: string
|
|
373
|
+
): Promise<ConflictInfo[]> {
|
|
374
|
+
const conflicts: ConflictInfo[] = [];
|
|
375
|
+
|
|
376
|
+
for (const filePath of files) {
|
|
377
|
+
// Check for exact matches or pattern overlaps
|
|
378
|
+
let query = `
|
|
379
|
+
SELECT
|
|
380
|
+
c.id as claim_id,
|
|
381
|
+
c.session_id,
|
|
382
|
+
s.name as session_name,
|
|
383
|
+
cf.file_path,
|
|
384
|
+
c.intent,
|
|
385
|
+
c.scope,
|
|
386
|
+
c.created_at
|
|
387
|
+
FROM claim_files cf
|
|
388
|
+
JOIN claims c ON cf.claim_id = c.id
|
|
389
|
+
JOIN sessions s ON c.session_id = s.id
|
|
390
|
+
WHERE c.status = 'active'
|
|
391
|
+
AND s.status = 'active'
|
|
392
|
+
AND (cf.file_path = ? OR (cf.is_pattern = 1 AND ? GLOB cf.file_path))
|
|
393
|
+
`;
|
|
394
|
+
const bindings: string[] = [filePath, filePath];
|
|
395
|
+
|
|
396
|
+
if (excludeSessionId) {
|
|
397
|
+
query += ' AND c.session_id != ?';
|
|
398
|
+
bindings.push(excludeSessionId);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const result = await db
|
|
402
|
+
.prepare(query)
|
|
403
|
+
.bind(...bindings)
|
|
404
|
+
.all<ConflictInfo>();
|
|
405
|
+
|
|
406
|
+
conflicts.push(...result.results);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Deduplicate by claim_id + file_path
|
|
410
|
+
const seen = new Set<string>();
|
|
411
|
+
return conflicts.filter((c) => {
|
|
412
|
+
const key = `${c.claim_id}:${c.file_path}`;
|
|
413
|
+
if (seen.has(key)) return false;
|
|
414
|
+
seen.add(key);
|
|
415
|
+
return true;
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export async function releaseClaim(
|
|
420
|
+
db: D1Database,
|
|
421
|
+
id: string,
|
|
422
|
+
params: {
|
|
423
|
+
status: 'completed' | 'abandoned';
|
|
424
|
+
summary?: string;
|
|
425
|
+
}
|
|
426
|
+
): Promise<boolean> {
|
|
427
|
+
const now = new Date().toISOString();
|
|
428
|
+
|
|
429
|
+
const result = await db
|
|
430
|
+
.prepare('UPDATE claims SET status = ?, updated_at = ?, completed_summary = ? WHERE id = ?')
|
|
431
|
+
.bind(params.status, now, params.summary ?? null, id)
|
|
432
|
+
.run();
|
|
433
|
+
|
|
434
|
+
return result.meta.changes > 0;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ============ Message Queries ============
|
|
438
|
+
|
|
439
|
+
export async function sendMessage(
|
|
440
|
+
db: D1Database,
|
|
441
|
+
params: {
|
|
442
|
+
from_session_id: string;
|
|
443
|
+
to_session_id?: string;
|
|
444
|
+
content: string;
|
|
445
|
+
}
|
|
446
|
+
): Promise<Message> {
|
|
447
|
+
const id = generateId();
|
|
448
|
+
const now = new Date().toISOString();
|
|
449
|
+
|
|
450
|
+
await db
|
|
451
|
+
.prepare(
|
|
452
|
+
`INSERT INTO messages (id, from_session_id, to_session_id, content, created_at)
|
|
453
|
+
VALUES (?, ?, ?, ?, ?)`
|
|
454
|
+
)
|
|
455
|
+
.bind(id, params.from_session_id, params.to_session_id ?? null, params.content, now)
|
|
456
|
+
.run();
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
id,
|
|
460
|
+
from_session_id: params.from_session_id,
|
|
461
|
+
to_session_id: params.to_session_id ?? null,
|
|
462
|
+
content: params.content,
|
|
463
|
+
read_at: null,
|
|
464
|
+
created_at: now,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export async function listMessages(
|
|
469
|
+
db: D1Database,
|
|
470
|
+
params: {
|
|
471
|
+
session_id: string;
|
|
472
|
+
unread_only?: boolean;
|
|
473
|
+
mark_as_read?: boolean;
|
|
474
|
+
}
|
|
475
|
+
): Promise<Message[]> {
|
|
476
|
+
let query = `
|
|
477
|
+
SELECT * FROM messages
|
|
478
|
+
WHERE (to_session_id = ? OR to_session_id IS NULL)
|
|
479
|
+
`;
|
|
480
|
+
const bindings: string[] = [params.session_id];
|
|
481
|
+
|
|
482
|
+
if (params.unread_only) {
|
|
483
|
+
query += ' AND read_at IS NULL';
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
query += ' ORDER BY created_at DESC';
|
|
487
|
+
|
|
488
|
+
const messages = await db
|
|
489
|
+
.prepare(query)
|
|
490
|
+
.bind(...bindings)
|
|
491
|
+
.all<Message>();
|
|
492
|
+
|
|
493
|
+
// Mark as read if requested
|
|
494
|
+
if (params.mark_as_read && messages.results.length > 0) {
|
|
495
|
+
const now = new Date().toISOString();
|
|
496
|
+
const ids = messages.results.map((m) => m.id);
|
|
497
|
+
|
|
498
|
+
for (const id of ids) {
|
|
499
|
+
await db.prepare('UPDATE messages SET read_at = ? WHERE id = ? AND read_at IS NULL').bind(now, id).run();
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return messages.results;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ============ Decision Queries ============
|
|
507
|
+
|
|
508
|
+
export async function addDecision(
|
|
509
|
+
db: D1Database,
|
|
510
|
+
params: {
|
|
511
|
+
session_id: string;
|
|
512
|
+
category?: DecisionCategory;
|
|
513
|
+
title: string;
|
|
514
|
+
description: string;
|
|
515
|
+
}
|
|
516
|
+
): Promise<Decision> {
|
|
517
|
+
const id = generateId();
|
|
518
|
+
const now = new Date().toISOString();
|
|
519
|
+
|
|
520
|
+
await db
|
|
521
|
+
.prepare(
|
|
522
|
+
`INSERT INTO decisions (id, session_id, category, title, description, created_at)
|
|
523
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
524
|
+
)
|
|
525
|
+
.bind(id, params.session_id, params.category ?? null, params.title, params.description, now)
|
|
526
|
+
.run();
|
|
527
|
+
|
|
528
|
+
return {
|
|
529
|
+
id,
|
|
530
|
+
session_id: params.session_id,
|
|
531
|
+
category: params.category ?? null,
|
|
532
|
+
title: params.title,
|
|
533
|
+
description: params.description,
|
|
534
|
+
created_at: now,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export async function listDecisions(
|
|
539
|
+
db: D1Database,
|
|
540
|
+
params: {
|
|
541
|
+
category?: DecisionCategory;
|
|
542
|
+
limit?: number;
|
|
543
|
+
} = {}
|
|
544
|
+
): Promise<Decision[]> {
|
|
545
|
+
let query = 'SELECT * FROM decisions WHERE 1=1';
|
|
546
|
+
const bindings: (string | number)[] = [];
|
|
547
|
+
|
|
548
|
+
if (params.category) {
|
|
549
|
+
query += ' AND category = ?';
|
|
550
|
+
bindings.push(params.category);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
query += ' ORDER BY created_at DESC LIMIT ?';
|
|
554
|
+
bindings.push(params.limit ?? 20);
|
|
555
|
+
|
|
556
|
+
const result = await db
|
|
557
|
+
.prepare(query)
|
|
558
|
+
.bind(...bindings)
|
|
559
|
+
.all<Decision>();
|
|
560
|
+
|
|
561
|
+
return result.results;
|
|
562
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// SQLite adapter: wraps better-sqlite3 to match D1-like API
|
|
2
|
+
// This allows the same queries.ts to work with both D1 and local SQLite
|
|
3
|
+
|
|
4
|
+
import Database from 'better-sqlite3';
|
|
5
|
+
import { randomUUID } from 'crypto';
|
|
6
|
+
import { existsSync, mkdirSync } from 'fs';
|
|
7
|
+
import { dirname, join } from 'path';
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
|
|
10
|
+
// Polyfill crypto.randomUUID for Node.js
|
|
11
|
+
if (typeof globalThis.crypto === 'undefined') {
|
|
12
|
+
(globalThis as unknown as { crypto: { randomUUID: () => string } }).crypto = {
|
|
13
|
+
randomUUID,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface D1Result<T> {
|
|
18
|
+
results: T[];
|
|
19
|
+
meta: {
|
|
20
|
+
changes: number;
|
|
21
|
+
last_row_id: number;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface D1PreparedStatement {
|
|
26
|
+
bind(...values: unknown[]): D1PreparedStatement;
|
|
27
|
+
first<T>(): Promise<T | null>;
|
|
28
|
+
all<T>(): Promise<D1Result<T>>;
|
|
29
|
+
run(): Promise<{ meta: { changes: number } }>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface D1Database {
|
|
33
|
+
prepare(sql: string): D1PreparedStatement;
|
|
34
|
+
batch(statements: D1PreparedStatement[]): Promise<D1Result<unknown>[]>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
class SqlitePreparedStatement implements D1PreparedStatement {
|
|
38
|
+
private bindings: unknown[] = [];
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
private db: Database.Database,
|
|
42
|
+
private sql: string
|
|
43
|
+
) {}
|
|
44
|
+
|
|
45
|
+
bind(...values: unknown[]): D1PreparedStatement {
|
|
46
|
+
this.bindings = values;
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async first<T>(): Promise<T | null> {
|
|
51
|
+
const stmt = this.db.prepare(this.sql);
|
|
52
|
+
const result = stmt.get(...this.bindings) as T | undefined;
|
|
53
|
+
return result ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async all<T>(): Promise<D1Result<T>> {
|
|
57
|
+
const stmt = this.db.prepare(this.sql);
|
|
58
|
+
const results = stmt.all(...this.bindings) as T[];
|
|
59
|
+
return {
|
|
60
|
+
results,
|
|
61
|
+
meta: { changes: 0, last_row_id: 0 },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async run(): Promise<{ meta: { changes: number } }> {
|
|
66
|
+
const stmt = this.db.prepare(this.sql);
|
|
67
|
+
const result = stmt.run(...this.bindings);
|
|
68
|
+
return {
|
|
69
|
+
meta: { changes: result.changes },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Internal method for batch execution
|
|
74
|
+
_run(): Database.RunResult {
|
|
75
|
+
const stmt = this.db.prepare(this.sql);
|
|
76
|
+
return stmt.run(...this.bindings);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class SqliteDatabase implements D1Database {
|
|
81
|
+
private db: Database.Database;
|
|
82
|
+
|
|
83
|
+
constructor(dbPath: string) {
|
|
84
|
+
// Ensure directory exists
|
|
85
|
+
const dir = dirname(dbPath);
|
|
86
|
+
if (!existsSync(dir)) {
|
|
87
|
+
mkdirSync(dir, { recursive: true });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.db = new Database(dbPath);
|
|
91
|
+
this.db.pragma('journal_mode = WAL');
|
|
92
|
+
this.db.pragma('foreign_keys = ON');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
prepare(sql: string): D1PreparedStatement {
|
|
96
|
+
return new SqlitePreparedStatement(this.db, sql);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async batch(statements: D1PreparedStatement[]): Promise<D1Result<unknown>[]> {
|
|
100
|
+
const transaction = this.db.transaction(() => {
|
|
101
|
+
return statements.map((stmt) => {
|
|
102
|
+
const sqliteStmt = stmt as SqlitePreparedStatement;
|
|
103
|
+
const result = sqliteStmt._run();
|
|
104
|
+
return {
|
|
105
|
+
results: [],
|
|
106
|
+
meta: { changes: result.changes, last_row_id: Number(result.lastInsertRowid) },
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
return transaction();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Initialize database schema
|
|
114
|
+
initSchema(migrations: string[]): void {
|
|
115
|
+
for (const migration of migrations) {
|
|
116
|
+
this.db.exec(migration);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
close(): void {
|
|
121
|
+
this.db.close();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Get default database path
|
|
126
|
+
export function getDefaultDbPath(): string {
|
|
127
|
+
const dataDir = join(homedir(), '.claude', 'session-collab');
|
|
128
|
+
return join(dataDir, 'collab.db');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Create a local SQLite database
|
|
132
|
+
export function createLocalDatabase(dbPath?: string): SqliteDatabase {
|
|
133
|
+
const path = dbPath ?? getDefaultDbPath();
|
|
134
|
+
return new SqliteDatabase(path);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export { SqliteDatabase };
|