trails/apps/planner/app/lib/sessions.ts
Ullrich Schäfer 6f18ce8099
fix(planner): bound /api/sessions listing (default 50, max 200)
Addresses planner-audit #8. `listSessions()` had no LIMIT — every call
to `GET /api/sessions` returned every non-closed session and did a
full table scan ordered by last_activity. On a long-running planner
host that's both a memory cliff and a query-plan footgun.

Now: defaults to 50 rows, clamps to [1, 200], accepts `?limit=` for
pagination-light scenarios. `sessions_last_activity_idx` (added in
#438) backs the ORDER BY + LIMIT efficiently.
2026-05-26 00:46:38 +02:00

135 lines
3.7 KiB
TypeScript

import { randomUUID } from "node:crypto";
import * as Y from "yjs";
import { eq, and, desc, lt } from "drizzle-orm";
import { getOrCreateDoc, deleteDoc } from "./yjs-server.ts";
import { getDb } from "./db.ts";
import { sessions } from "@trails-cool/db/schema/planner";
export type SessionMetadata = typeof sessions.$inferSelect;
export async function createSession(options?: {
callbackUrl?: string;
callbackToken?: string;
}): Promise<SessionMetadata> {
const id = randomUUID();
const doc = getOrCreateDoc(id);
const [row] = await getDb()
.insert(sessions)
.values({
id,
yjsState: Buffer.from(Y.encodeStateAsUpdate(doc)),
callbackUrl: options?.callbackUrl,
callbackToken: options?.callbackToken,
})
.returning();
return row!;
}
export async function getSession(id: string): Promise<SessionMetadata | undefined> {
const [row] = await getDb()
.select()
.from(sessions)
.where(and(eq(sessions.id, id), eq(sessions.closed, false)));
return row;
}
export async function touchSession(id: string): Promise<void> {
await getDb()
.update(sessions)
.set({ lastActivity: new Date() })
.where(eq(sessions.id, id));
}
export async function saveSessionState(id: string): Promise<void> {
const doc = getOrCreateDoc(id);
const state = Y.encodeStateAsUpdate(doc);
await getDb()
.update(sessions)
.set({ yjsState: Buffer.from(state), lastActivity: new Date() })
.where(eq(sessions.id, id));
}
export async function loadSessionState(id: string): Promise<Uint8Array | null> {
const [row] = await getDb()
.select({ yjsState: sessions.yjsState })
.from(sessions)
.where(eq(sessions.id, id));
return row?.yjsState ? new Uint8Array(row.yjsState) : null;
}
export async function closeSession(id: string): Promise<boolean> {
const result = await getDb()
.update(sessions)
.set({ closed: true })
.where(and(eq(sessions.id, id), eq(sessions.closed, false)))
.returning({ id: sessions.id });
deleteDoc(id);
return result.length > 0;
}
export function initializeSessionWithWaypoints(
sessionId: string,
waypoints: Array<{ lat: number; lon: number; name?: string }>,
): void {
const doc = getOrCreateDoc(sessionId);
const yWaypoints = doc.getArray("waypoints");
doc.transact(() => {
for (const wp of waypoints) {
const yMap = new Y.Map();
yMap.set("lat", wp.lat);
yMap.set("lon", wp.lon);
if (wp.name) yMap.set("name", wp.name);
yWaypoints.push([yMap]);
}
});
}
export function initializeSessionWithNoGoAreas(
sessionId: string,
noGoAreas: Array<{ points: Array<{ lat: number; lon: number }> }>,
): void {
const doc = getOrCreateDoc(sessionId);
const yNoGoAreas = doc.getArray("noGoAreas");
doc.transact(() => {
for (const area of noGoAreas) {
const yMap = new Y.Map();
yMap.set("points", area.points);
yNoGoAreas.push([yMap]);
}
});
}
// Default page size for the listing API. Hard cap so an unbounded
// caller can't request thousands of rows (or trigger a full scan).
const DEFAULT_LIST_LIMIT = 50;
const MAX_LIST_LIMIT = 200;
export async function listSessions(limit: number = DEFAULT_LIST_LIMIT): Promise<SessionMetadata[]> {
const bounded = Math.min(Math.max(1, limit), MAX_LIST_LIMIT);
return getDb()
.select()
.from(sessions)
.where(eq(sessions.closed, false))
.orderBy(desc(sessions.lastActivity))
.limit(bounded);
}
export async function expireSessions(maxAgeDays: number = 7): Promise<number> {
const cutoff = new Date(Date.now() - maxAgeDays * 24 * 60 * 60 * 1000);
const result = await getDb()
.delete(sessions)
.where(lt(sessions.lastActivity, cutoff))
.returning({ id: sessions.id });
for (const row of result) {
deleteDoc(row.id);
}
return result.length;
}