lynkr
Version:
Self-hosted LLM gateway and tier-routing proxy for Claude Code, Cursor, and Codex. Routes across Ollama, AWS Bedrock, OpenRouter, Databricks, Azure OpenAI, llama.cpp, and LM Studio with prompt caching, MCP tools, and 60-80% cost savings.
229 lines (205 loc) • 6.48 kB
JavaScript
const db = require("../db");
const logger = require("../logger");
const selectSessionStmt = db.prepare(
"SELECT id, created_at, updated_at, metadata FROM sessions WHERE id = ?",
);
// Limit history to last 50 entries to prevent unbounded memory growth.
// Older entries remain in DB for auditing but aren't loaded into memory.
const MAX_HISTORY_ROWS = 50;
const selectHistoryStmt = db.prepare(
`SELECT role, type, status, content, metadata, timestamp
FROM session_history
WHERE session_id = ?
ORDER BY timestamp DESC, id DESC
LIMIT ${MAX_HISTORY_ROWS}`,
);
const insertSessionStmt = db.prepare(
"INSERT INTO sessions (id, created_at, updated_at, metadata) VALUES (@id, @created_at, @updated_at, @metadata)",
);
const updateSessionStmt = db.prepare(
"UPDATE sessions SET updated_at = @updated_at, metadata = @metadata WHERE id = @id",
);
const updateSessionTimestampStmt = db.prepare(
"UPDATE sessions SET updated_at = @updated_at WHERE id = @id",
);
const deleteSessionStmt = db.prepare("DELETE FROM sessions WHERE id = ?");
const deleteHistoryStmt = db.prepare("DELETE FROM session_history WHERE session_id = ?");
const insertHistoryStmt = db.prepare(
`INSERT INTO session_history (session_id, role, type, status, content, metadata, timestamp)
VALUES (@session_id, @role, @type, @status, @content, @metadata, @timestamp)`,
);
const cleanupOldSessionsStmt = db.prepare(`
DELETE FROM sessions
WHERE updated_at < ?
`);
const cleanupOldHistoryStmt = db.prepare(`
DELETE FROM session_history
WHERE timestamp < ?
`);
function parseJSON(value, fallback) {
if (value === null || value === undefined) return fallback;
try {
return JSON.parse(value);
} catch (err) {
logger.warn({ err }, "Failed to parse JSON from session store");
return fallback;
}
}
function serialize(value) {
if (value === undefined) return null;
try {
return JSON.stringify(value);
} catch (err) {
logger.warn({ err }, "Failed to serialize JSON for session store");
return null;
}
}
function toSession(row, historyRows = []) {
return {
id: row.id,
createdAt: row.created_at,
updatedAt: row.updated_at,
metadata: parseJSON(row.metadata, {}) ?? {},
history: historyRows.map((item) => ({
role: item.role ?? undefined,
type: item.type ?? undefined,
status: item.status ?? undefined,
content: parseJSON(item.content, null),
metadata: parseJSON(item.metadata, null) ?? undefined,
timestamp: item.timestamp,
})),
};
}
function getSession(sessionId) {
if (!sessionId) return null;
const sessionRow = selectSessionStmt.get(sessionId);
if (!sessionRow) return null;
// Query returns rows in DESC order (for LIMIT to grab newest), reverse to ASC
const historyRows = selectHistoryStmt.all(sessionId).reverse();
return toSession(sessionRow, historyRows);
}
function createSession(sessionId, metadata = {}) {
const now = Date.now();
insertSessionStmt.run({
id: sessionId,
created_at: now,
updated_at: now,
metadata: serialize(metadata) ?? "{}",
});
return {
id: sessionId,
createdAt: now,
updatedAt: now,
metadata: metadata ?? {},
history: [],
};
}
function getOrCreateSession(sessionId) {
const existing = getSession(sessionId);
if (existing) {
// [SESSION_DEBUG] Reusing existing session
const ageMs = Date.now() - existing.createdAt;
const ageHours = (ageMs / (1000 * 60 * 60)).toFixed(2);
logger.debug({
sessionId,
ageMs,
ageHours,
historyEntries: existing.history?.length ?? 0
}, '[SESSION_DEBUG] Reusing existing session');
return existing;
}
try {
// [SESSION_DEBUG] Created new session
const newSession = createSession(sessionId);
logger.debug({
sessionId,
createdAt: newSession.createdAt
}, '[SESSION_DEBUG] Created NEW session');
return newSession;
} catch (err) {
if (err.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
return getSession(sessionId);
}
throw err;
}
}
function upsertSession(sessionId, data = {}) {
if (!sessionId) return null;
const metadata = data.metadata ?? {};
const updatedAt = data.updatedAt ?? Date.now();
const createdAt = data.createdAt ?? updatedAt;
const existing = selectSessionStmt.get(sessionId);
if (!existing) {
insertSessionStmt.run({
id: sessionId,
created_at: createdAt,
updated_at: updatedAt,
metadata: serialize(metadata) ?? "{}",
});
} else {
updateSessionStmt.run({
id: sessionId,
updated_at: updatedAt,
metadata: serialize(metadata) ?? "{}",
});
}
return getSession(sessionId);
}
function appendSessionTurn(sessionId, turn, metadata) {
if (!sessionId) return null;
const timestamp = turn.timestamp ?? Date.now();
const params = {
session_id: sessionId,
role: turn.role ?? null,
type: turn.type ?? null,
status: typeof turn.status === "number" ? turn.status : null,
content: serialize(turn.content),
metadata: serialize(turn.metadata),
timestamp,
};
logger.debug({ params }, "Inserting session history row");
insertHistoryStmt.run(params);
logger.debug({ sessionId, timestamp, metadata }, "Updating session metadata");
if (metadata !== undefined) {
updateSessionStmt.run({
id: sessionId,
updated_at: timestamp,
metadata: serialize(metadata) ?? "{}",
});
} else {
updateSessionTimestampStmt.run({
id: sessionId,
updated_at: timestamp,
});
}
return { ...turn, timestamp };
}
function deleteSession(sessionId) {
if (!sessionId) return;
const deleteHistory = db.transaction((id) => {
deleteHistoryStmt.run(id);
deleteSessionStmt.run(id);
});
deleteHistory(sessionId);
}
function cleanupOldSessions(maxAgeMs = 7 * 24 * 60 * 60 * 1000) {
const cutoffTime = Date.now() - maxAgeMs;
const result = cleanupOldSessionsStmt.run(cutoffTime);
logger.info({ deleted: result.changes, maxAgeMs }, "Cleaned up old sessions");
return result.changes;
}
function cleanupOldHistory(maxAgeMs = 30 * 24 * 60 * 60 * 1000) {
const cutoffTime = Date.now() - maxAgeMs;
const result = cleanupOldHistoryStmt.run(cutoffTime);
logger.info({ deleted: result.changes, maxAgeMs }, "Cleaned up old history");
return result.changes;
}
module.exports = {
getSession,
getOrCreateSession,
upsertSession,
appendSessionTurn,
deleteSession,
cleanupOldSessions,
cleanupOldHistory,
};