refactor: move rename and delete sessions to modules

This commit is contained in:
Haileyesus
2026-04-27 14:21:03 +03:00
parent 7ceaa9e326
commit 9663f08fcb
13 changed files with 144 additions and 368 deletions

View File

@@ -7,6 +7,6 @@ export { notificationPreferencesDb } from '@/modules/database/repositories/notif
export { projectsDb } from '@/modules/database/repositories/projects.db.js';
export { pushSubscriptionsDb } from '@/modules/database/repositories/push-subscriptions.js';
export { scanStateDb } from '@/modules/database/repositories/scan-state.db.js';
export { sessionsDb, applyCustomSessionNames } from '@/modules/database/repositories/sessions.db.js';
export { sessionsDb } from '@/modules/database/repositories/sessions.db.js';
export { userDb } from '@/modules/database/repositories/users.js';
export { vapidKeysDb } from '@/modules/database/repositories/vapid-keys.js';

View File

@@ -3,11 +3,6 @@ import path from 'node:path';
import { getConnection } from '@/modules/database/connection.js';
import { projectsDb } from '@/modules/database/repositories/projects.db.js';
type SessionNameLookupRow = {
session_id: string;
custom_name: string;
};
type SessionRow = {
session_id: string;
provider: string;
@@ -23,11 +18,6 @@ type SessionMetadataLookupRow = Pick<
'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'created_at' | 'updated_at'
>;
type LegacySessionSummary = {
id: string;
summary?: string;
};
function normalizeTimestamp(value?: string): string | null {
if (!value) return null;
@@ -179,111 +169,8 @@ export const sessionsDb = {
return row?.custom_name ?? null;
},
getSessionNames(sessionIds: string[], provider: string): Map<string, string> {
if (sessionIds.length === 0) return new Map();
deleteSessionById(sessionId: string): boolean {
const db = getConnection();
const placeholders = sessionIds.map(() => '?').join(',');
const rows = db
.prepare(
`SELECT session_id, custom_name
FROM sessions
WHERE session_id IN (${placeholders})
AND provider = ?
AND custom_name IS NOT NULL`
)
.all(...sessionIds, provider) as SessionNameLookupRow[];
return new Map(rows.map((row) => [row.session_id, row.custom_name]));
},
/**
* Legacy-compatibility method kept for parity with `server/database/db.js`.
*
* Renaming a session is a metadata-only change — it's not actual activity,
* so existing rows intentionally keep their `updated_at` untouched. This
* prevents the sidebar's "last activity" timestamp from jumping around when
* a user simply edits a session's label.
*
* When the row doesn't exist yet we still have to seed `created_at`/
* `updated_at`; we write ISO-8601 UTC (with the `Z` suffix) rather than
* rely on SQLite's `CURRENT_TIMESTAMP`, which stores a naive
* `"YYYY-MM-DD HH:MM:SS"` value that JavaScript's `new Date(...)` parses as
* local time and displays with the wrong offset.
*
* TODO: Remove after all legacy imports are migrated to the new repository API.
*/
setName(sessionId: string, provider: string, customName: string): void {
const db = getConnection();
const nowIso = new Date().toISOString();
db.prepare(
`INSERT INTO sessions (session_id, provider, custom_name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_id, provider) DO UPDATE SET
custom_name = excluded.custom_name`
).run(sessionId, provider, customName, nowIso, nowIso);
},
/**
* Legacy-compatibility method kept for parity with `server/database/db.js`.
* TODO: Remove after all legacy imports are migrated to the new repository API.
*/
getName(sessionId: string, provider: string): string | null {
return sessionsDb.getSessionName(sessionId, provider);
},
/**
* Legacy-compatibility method kept for parity with `server/database/db.js`.
* TODO: Remove after all legacy imports are migrated to the new repository API.
*/
getNames(sessionIds: string[], provider: string): Map<string, string> {
return sessionsDb.getSessionNames(sessionIds, provider);
},
/**
* Legacy-compatibility method kept for parity with `server/database/db.js`.
* TODO: Remove after all legacy imports are migrated to the new repository API.
*/
deleteName(sessionId: string, provider: string): boolean {
const db = getConnection();
return (
db
.prepare(
`DELETE FROM sessions
WHERE session_id = ? AND provider = ?`
)
.run(sessionId, provider).changes > 0
);
},
deleteSession(sessionId: string): void {
const db = getConnection();
db.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionId);
return db.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionId).changes > 0;
},
};
/**
* Legacy-compatibility helper kept for parity with `server/database/db.js`.
* TODO: Remove after all legacy imports are migrated to the new repository API.
*/
export function applyCustomSessionNames(
sessions: LegacySessionSummary[] | null | undefined,
provider: string
): void {
if (!sessions?.length) return;
try {
const sessionIds = sessions.map((session) => session.id);
const customNames = sessionsDb.getNames(sessionIds, provider);
for (const session of sessions) {
const customName = customNames.get(session.id);
if (customName) {
session.summary = customName;
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[DB] Failed to apply custom session names for ${provider}:`, message);
}
}

View File

@@ -2,6 +2,7 @@ import express, { type Request, type Response } from 'express';
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
import { sessionsService } from '@/modules/providers/services/sessions.service.js';
import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js';
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
@@ -25,6 +26,20 @@ const readPathParam = (value: unknown, name: string): string => {
const normalizeProviderParam = (value: unknown): string =>
readPathParam(value, 'provider').trim().toLowerCase();
const SESSION_ID_PATTERN = /^[a-zA-Z0-9._-]{1,120}$/;
const parseSessionId = (value: unknown): string => {
const sessionId = readPathParam(value, 'sessionId').trim();
if (!SESSION_ID_PATTERN.test(sessionId)) {
throw new AppError('Invalid sessionId.', {
code: 'INVALID_SESSION_ID',
statusCode: 400,
});
}
return sessionId;
};
const readOptionalQueryString = (value: unknown): string | undefined => {
if (typeof value !== 'string') {
return undefined;
@@ -143,6 +158,33 @@ const parseProvider = (value: unknown): LLMProvider => {
});
};
const parseSessionRenameSummary = (payload: unknown): string => {
if (!payload || typeof payload !== 'object') {
throw new AppError('Request body must be an object.', {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const body = payload as Record<string, unknown>;
const summary = typeof body.summary === 'string' ? body.summary.trim() : '';
if (!summary) {
throw new AppError('Summary is required.', {
code: 'INVALID_SESSION_SUMMARY',
statusCode: 400,
});
}
if (summary.length > 500) {
throw new AppError('Summary must not exceed 500 characters.', {
code: 'INVALID_SESSION_SUMMARY',
statusCode: 400,
});
}
return summary;
};
router.get(
'/:provider/auth/status',
asyncHandler(async (req: Request, res: Response) => {
@@ -214,4 +256,23 @@ router.post(
}),
);
router.delete(
'/sessions/:sessionId',
asyncHandler(async (req: Request, res: Response) => {
const sessionId = parseSessionId(req.params.sessionId);
const result = await sessionsService.deleteSessionById(sessionId);
res.json(createApiSuccessResponse(result));
}),
);
router.put(
'/sessions/:sessionId',
asyncHandler(async (req: Request, res: Response) => {
const sessionId = parseSessionId(req.params.sessionId);
const summary = parseSessionRenameSummary(req.body);
const result = sessionsService.renameSessionById(sessionId, summary);
res.json(createApiSuccessResponse(result));
}),
);
export default router;

View File

@@ -1,129 +1,16 @@
import path from 'node:path';
import fsp, { readFile } from 'node:fs/promises';
import { scanStateDb, sessionsDb, projectsDb } from '@/modules/database/index.js';
import { scanStateDb } from '@/modules/database/index.js';
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import { sessionsService } from '@/modules/providers/services/sessions.service.js';
import type { LLMProvider, NormalizedMessage } from '@/shared/types.js';
import { AppError } from '@/shared/utils.js';
import type { LLMProvider } from '@/shared/types.js';
type SessionSynchronizeResult = {
processedByProvider: Record<LLMProvider, number>;
failures: string[];
};
type SessionHistoryPayload = {
sessionId: string;
provider: string;
projectPath: string | null;
filePath: string;
fileType: 'jsonl' | 'json';
entries: unknown[];
messages: NormalizedMessage[];
};
const SESSION_ID_PATTERN = /^[a-zA-Z0-9._-]{1,120}$/;
/**
* Restricts session ids before they are used in DB and filesystem operations.
*/
function sanitizeSessionId(sessionId: string): string {
const value = String(sessionId).trim();
if (!SESSION_ID_PATTERN.test(value)) {
throw new AppError('Invalid session id format.', {
code: 'INVALID_SESSION_ID',
statusCode: 400,
});
}
return value;
}
/**
* Removes one file if it exists.
*/
async function removeFileIfExists(filePath: string): Promise<boolean> {
try {
await fsp.unlink(filePath);
return true;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return false;
}
throw error;
}
}
/**
* Parses newline-delimited JSON and preserves malformed lines as raw entries.
*/
function parseJsonl(content: string): unknown[] {
const entries: unknown[] = [];
const lines = content.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
try {
entries.push(JSON.parse(trimmed));
} catch {
entries.push({ raw: trimmed, parseError: true });
}
}
return entries;
}
/**
* Parses JSON and normalizes object payloads into a single-element array.
*/
function parseJson(content: string): unknown[] {
try {
const parsed = JSON.parse(content) as unknown;
return Array.isArray(parsed) ? parsed : [parsed];
} catch {
return [{ raw: content, parseError: true }];
}
}
/**
* Orchestrates provider-specific session indexers and indexed-session lifecycle operations.
*/
export const sessionSynchronizerService = {
/**
* Lists indexed sessions from DB, optionally scoped to one provider.
*/
listIndexedSessions(provider?: string) {
const allSessions = sessionsDb.getAllSessions();
if (!provider) {
return allSessions;
}
return allSessions.filter((session) => session.provider === provider);
},
/**
* Reads one indexed session row and enriches it with the associated project id.
*/
getIndexedSession(sessionId: string) {
const session = sessionsDb.getSessionById(sessionId);
if (!session) {
throw new AppError(`Session "${sessionId}" was not found.`, {
code: 'SESSION_NOT_FOUND',
statusCode: 404,
});
}
const project = session.project_path ? projectsDb.getProjectPath(session.project_path) : null;
return {
...session,
project_id: project?.project_id ?? null,
};
},
/**
* Runs all provider synchronizers and updates scan_state.last_scanned_at.
*/
@@ -177,84 +64,4 @@ export const sessionSynchronizerService = {
sessionId,
};
},
/**
* Updates one indexed session custom name after validating existence.
*/
updateSessionCustomName(sessionId: string, sessionCustomName: string): void {
const sessionMetadata = sessionsDb.getSessionById(sessionId);
if (!sessionMetadata) {
throw new AppError('Session not found.', {
code: 'SESSION_NOT_FOUND',
statusCode: 404,
});
}
sessionsDb.updateSessionCustomName(sessionId, sessionCustomName);
},
/**
* Deletes a session artifact path from disk (if present) and deletes DB metadata.
*/
async deleteSessionArtifacts(rawSessionId: string): Promise<{
sessionId: string;
deletedFromDisk: boolean;
deletedFromDatabase: boolean;
}> {
const sessionId = sanitizeSessionId(rawSessionId);
const existingSession = sessionsDb.getSessionById(sessionId);
const sessionFilePath = existingSession?.jsonl_path ?? null;
const deletedFromDisk = sessionFilePath ? await removeFileIfExists(sessionFilePath) : false;
if (existingSession) {
sessionsDb.deleteSession(sessionId);
}
return {
sessionId,
deletedFromDisk,
deletedFromDatabase: Boolean(existingSession),
};
},
/**
* Reads indexed session history directly from session json path and normalizes entries.
*/
async getSessionHistory(sessionId: string): Promise<SessionHistoryPayload> {
const session = sessionsDb.getSessionById(sessionId);
if (!session) {
throw new AppError(`Session "${sessionId}" was not found.`, {
code: 'SESSION_NOT_FOUND',
statusCode: 404,
});
}
if (!session.jsonl_path) {
throw new AppError(`Session "${sessionId}" does not have a history file path.`, {
code: 'SESSION_HISTORY_NOT_AVAILABLE',
statusCode: 404,
});
}
const filePath = session.jsonl_path;
const fileContent = await readFile(filePath, 'utf8');
const extension = path.extname(filePath).toLowerCase();
const isGeminiJson = session.provider === 'gemini' || extension === '.json';
const entries = isGeminiJson ? parseJson(fileContent) : parseJsonl(fileContent);
const messages: NormalizedMessage[] = [];
for (const entry of entries) {
messages.push(...sessionsService.normalizeMessage(session.provider, entry, session.session_id));
}
return {
sessionId: session.session_id,
provider: session.provider,
projectPath: session.project_path,
filePath,
fileType: isGeminiJson ? 'json' : 'jsonl',
entries,
messages,
};
},
};

View File

@@ -1,3 +1,6 @@
import fsp from 'node:fs/promises';
import { sessionsDb } from '@/modules/database/index.js';
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type {
FetchHistoryOptions,
@@ -5,6 +8,24 @@ import type {
LLMProvider,
NormalizedMessage,
} from '@/shared/types.js';
import { AppError } from '@/shared/utils.js';
/**
* Removes one file if it exists.
*/
async function removeFileIfExists(filePath: string): Promise<boolean> {
try {
await fsp.unlink(filePath);
return true;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return false;
}
throw error;
}
}
/**
* Application service for provider-backed session message operations.
@@ -42,4 +63,35 @@ export const sessionsService = {
): Promise<FetchHistoryResult> {
return providerRegistry.resolveProvider(providerName).sessions.fetchHistory(sessionId, options);
},
/**
* Deletes one persisted session row by id.
*/
deleteSessionById(sessionId: string): { sessionId: string } {
const deleted = sessionsDb.deleteSessionById(sessionId);
if (!deleted) {
throw new AppError(`Session "${sessionId}" was not found.`, {
code: 'SESSION_NOT_FOUND',
statusCode: 404,
});
}
return { sessionId };
},
/**
* Renames one session by id without requiring the caller to pass provider.
*/
renameSessionById(sessionId: string, summary: string): { sessionId: string; summary: string } {
const session = sessionsDb.getSessionById(sessionId);
if (!session) {
throw new AppError(`Session "${sessionId}" was not found.`, {
code: 'SESSION_NOT_FOUND',
statusCode: 404,
});
}
sessionsDb.updateSessionCustomName(sessionId, summary);
return { sessionId, summary };
},
};