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.
135 lines
3.7 KiB
TypeScript
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;
|
|
}
|