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 { 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 { 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 { await getDb() .update(sessions) .set({ lastActivity: new Date() }) .where(eq(sessions.id, id)); } export async function saveSessionState(id: string): Promise { 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 { 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 { 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 { 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 { 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; }