import { and, desc, eq, gt, inArray, sql } from "drizzle-orm"; import { randomHex } from "@/worker/auth/bytes"; import { calendarObjectMatchesFilters } from "@/worker/caldav/ical/search"; import type { CalendarSearchFilter, ParsedICalendar, RecurrenceBounds } from "@/worker/caldav/ical/types"; import { calendarHref as calHref, calendarObjectHref as objectHref } from "@/worker/caldav/paths"; import { davSyncToken, objectNameFromCollectionHref, parseDavSyncToken, parseDavSyncTokenStrict, validDavCollectionName, } from "@/worker/db/dav-collections"; import type { CalDavDoDb } from "@/worker/db/cal-dav-do/client"; import { calendarChanges, calendarDeadProps, calendarIndex, calendarObjects, calendars, calMeta, type CalendarDeadPropRow, type CalendarIndexRow, type CalendarObjectRow, type CalendarRow, } from "@/worker/db/cal-dav-do/schema"; import type { DavChangeType } from "@/worker/db/types"; export { createCalDavDoDb } from "./client"; export interface CalendarResource { kind: "home" | "calendar" | "object"; href: string; id: string; syncToken?: string; calendar?: CalendarRow; object?: CalendarObjectRow; index?: CalendarIndexRow; deadProps: CalendarDeadPropRow[]; } function calendarId(): string { return `cal_${randomHex(16)}`; } export function calendarObjectId(): string { return `cobj_${randomHex(16)}`; } export function validCalendarName(name: string): boolean { return validDavCollectionName(name); } export function calendarSyncToken(seq: number): string { return davSyncToken(seq); } export function calendarIndexRow( objectId: string, calendarId: string, parsed: ParsedICalendar, ): typeof calendarIndex.$inferInsert { return { objectId, calendarId, uid: parsed.uid, componentType: parsed.componentType, dtstartMs: parsed.dtstartMs, dtendMs: parsed.dtendMs, dueMs: parsed.dueMs, completedMs: parsed.completedMs, summary: parsed.summary, hasRecurrence: parsed.hasRecurrence, recurrenceMinMs: parsed.recurrenceMinMs, recurrenceMaxMs: parsed.recurrenceMaxMs, }; } export function ensureDefaultCalendar(db: CalDavDoDb, subjectId: string, nowMs: number): CalendarRow { const existing = calendarByName(db, "default"); if (existing) return existing; db.insert(calMeta).values({ key: "subject_id", value: subjectId, updatedAtMs: nowMs }).onConflictDoNothing().run(); db.insert(calendars) .values({ id: calendarId(), name: "default", displayName: "Default", description: null, timezoneIcal: null, color: null, orderIndex: 0, createdAtMs: nowMs, modifiedAtMs: nowMs, syncSeq: 0, }) .run(); return calendarByName(db, "default")!; } export function ensureCalendar( db: CalDavDoDb, subjectId: string, nowMs: number, calendarName: string, ): CalendarRow | null { if (calendarName === "default") return ensureDefaultCalendar(db, subjectId, nowMs); return calendarByName(db, calendarName) ?? null; } export function listCalendars(db: CalDavDoDb, subjectId: string, nowMs: number): CalendarRow[] { ensureDefaultCalendar(db, subjectId, nowMs); return db.select().from(calendars).orderBy(calendars.orderIndex, calendars.createdAtMs).all(); } export function calendarById(db: CalDavDoDb, id: string): CalendarRow | undefined { return db.query.calendars.findFirst({ where: eq(calendars.id, id) }).sync(); } export function calendarByName(db: CalDavDoDb, name: string): CalendarRow | undefined { return db.query.calendars.findFirst({ where: eq(calendars.name, name) }).sync(); } export function createCalendar( db: CalDavDoDb, input: { subjectId: string; name: string; displayName: string; description: string | null; color: string | null; orderIndex: number; timezoneIcal: string | null; nowMs: number; }, ): CalendarRow { ensureDefaultCalendar(db, input.subjectId, input.nowMs); const id = calendarId(); db.insert(calendars) .values({ id, name: input.name, displayName: input.displayName, description: input.description, timezoneIcal: input.timezoneIcal, color: input.color, orderIndex: input.orderIndex, createdAtMs: input.nowMs, modifiedAtMs: input.nowMs, syncSeq: 0, }) .run(); return calendarById(db, id)!; } export function updateCalendar( db: CalDavDoDb, input: { id: string; displayName?: string; description?: string | null; color?: string | null; orderIndex?: number; timezoneIcal?: string | null; nowMs: number; }, ): CalendarRow | undefined { const existing = calendarById(db, input.id); if (!existing) return undefined; db.update(calendars) .set({ displayName: input.displayName ?? existing.displayName, description: input.description === undefined ? existing.description : input.description, color: input.color === undefined ? existing.color : input.color, orderIndex: input.orderIndex ?? existing.orderIndex, timezoneIcal: input.timezoneIcal === undefined ? existing.timezoneIcal : input.timezoneIcal, modifiedAtMs: input.nowMs, syncSeq: sql`${calendars.syncSeq} + 1`, }) .where(eq(calendars.id, input.id)) .run(); return calendarById(db, input.id); } export function deleteCalendar(db: CalDavDoDb, id: string): boolean { const existing = calendarById(db, id); if (!existing) return false; const objectIds = db .select({ id: calendarObjects.id }) .from(calendarObjects) .where(eq(calendarObjects.calendarId, id)) .all(); db.transaction((tx) => { tx.delete(calendarDeadProps) .where(and(eq(calendarDeadProps.resourceKind, "calendar"), eq(calendarDeadProps.resourceId, id))) .run(); if (objectIds.length > 0) { tx.delete(calendarDeadProps) .where( and( eq(calendarDeadProps.resourceKind, "object"), inArray( calendarDeadProps.resourceId, objectIds.map((row) => row.id), ), ), ) .run(); } tx.delete(calendars).where(eq(calendars.id, id)).run(); }); return true; } export function homeResource(db: CalDavDoDb): CalendarResource { return { kind: "home", href: "/calendars/", id: "home", deadProps: deadProps(db, "home", "home") }; } export function calendarResource(db: CalDavDoDb, calendar: CalendarRow): CalendarResource { return { kind: "calendar", href: calHref(calendar.name), id: calendar.id, syncToken: calendarSyncToken(latestChangeSeq(db)), calendar, deadProps: deadProps(db, "calendar", calendar.id), }; } export function objectResource(db: CalDavDoDb, calendar: CalendarRow, object: CalendarObjectRow): CalendarResource { return { kind: "object", href: objectHref(calendar.name, object.name), id: object.id, calendar, object, index: db.query.calendarIndex.findFirst({ where: eq(calendarIndex.objectId, object.id) }).sync(), deadProps: deadProps(db, "object", object.id), }; } export interface ObjectResourcesResult { resources: CalendarResource[]; truncated: boolean; } export function objectResources( db: CalDavDoDb, calendar: CalendarRow, maxResults: number, filters: CalendarSearchFilter[] = [], recurrenceBounds?: RecurrenceBounds, ): ObjectResourcesResult { const { rows, truncated } = filteredObjects(db, calendar.id, maxResults, filters, recurrenceBounds); return { resources: rows.map((row) => objectResource(db, calendar, row)), truncated }; } export function objectByName(db: CalDavDoDb, calendarId: string, name: string): CalendarObjectRow | undefined { return db.query.calendarObjects .findFirst({ where: and(eq(calendarObjects.calendarId, calendarId), eq(calendarObjects.name, name)) }) .sync(); } export function objectByUid(db: CalDavDoDb, calendarId: string, uid: string): CalendarObjectRow | undefined { return db.query.calendarObjects .findFirst({ where: and(eq(calendarObjects.calendarId, calendarId), eq(calendarObjects.uid, uid)) }) .sync(); } export function objectNameFromHref(calendar: CalendarRow, href: string, acceptedOrigins?: string[]): string | null { return objectNameFromCollectionHref(calHref(calendar.name), href, acceptedOrigins); } export function objectResourceFromHref( db: CalDavDoDb, calendar: CalendarRow, href: string, acceptedOrigins?: string[], ): CalendarResource | null { const name = objectNameFromHref(calendar, href, acceptedOrigins); if (!name) return null; const object = objectByName(db, calendar.id, name); return object ? objectResource(db, calendar, object) : null; } export function latestChangeSeq(db: CalDavDoDb): number { return ( db.select({ seq: calendarChanges.seq }).from(calendarChanges).orderBy(desc(calendarChanges.seq)).limit(1).all()[0] ?.seq ?? 0 ); } export interface ChangesSinceResult { changes: { seq: number; calendarId: string | null; href: string; changeType: DavChangeType; changedAtMs: number }[]; truncated: boolean; } export function changesSince( db: CalDavDoDb, calendar: CalendarRow, token: string | null, maxResults: number, ): ChangesSinceResult { const since = parseDavSyncToken(token); const rows = db .select() .from(calendarChanges) .where(and(eq(calendarChanges.calendarId, calendar.id), gt(calendarChanges.seq, since))) .orderBy(calendarChanges.seq) .limit(maxResults + 1) .all(); const truncated = rows.length > maxResults; return { changes: truncated ? rows.slice(0, maxResults) : rows, truncated }; } export function syncTokenSeq(token: string | null): number | null { return parseDavSyncTokenStrict(token); } export function recordChange( db: CalDavDoDb, calendarId: string, href: string, changeType: DavChangeType, nowMs: number, ): void { db.insert(calendarChanges).values({ calendarId, href, changeType, changedAtMs: nowMs }).run(); db.update(calendars) .set({ modifiedAtMs: nowMs, syncSeq: sql`${calendars.syncSeq} + 1` }) .where(eq(calendars.id, calendarId)) .run(); } function deadProps(db: CalDavDoDb, kind: "home" | "calendar" | "object", resourceId: string): CalendarDeadPropRow[] { return db .select() .from(calendarDeadProps) .where(and(eq(calendarDeadProps.resourceKind, kind), eq(calendarDeadProps.resourceId, resourceId))) .all(); } function filteredObjects( db: CalDavDoDb, calendarId: string, maxResults: number, filters: CalendarSearchFilter[], recurrenceBounds?: RecurrenceBounds, ): { rows: CalendarObjectRow[]; truncated: boolean } { if (filters.length === 0) { const rows = db .select() .from(calendarObjects) .where(eq(calendarObjects.calendarId, calendarId)) .limit(maxResults + 1) .all(); const truncated = rows.length > maxResults; return { rows: truncated ? rows.slice(0, maxResults) : rows, truncated }; } const bounds = recurrenceBounds ?? { maxYears: 5, maxInstances: 10_000 }; const matched = candidateObjects(db, calendarId, filters).filter((row) => calendarObjectMatchesFilters(row.body, filters, bounds), ); const truncated = matched.length > maxResults; return { rows: matched.slice(0, maxResults), truncated }; } function candidateObjects(db: CalDavDoDb, calendarId: string, filters: CalendarSearchFilter[]): CalendarObjectRow[] { const componentFilters = filters.filter((filter) => filter.kind === "component").map((filter) => filter.component); if (componentFilters.length === 0) { return db.select().from(calendarObjects).where(eq(calendarObjects.calendarId, calendarId)).all(); } const componentTypes = [...new Set(componentFilters)]; return db .select() .from(calendarObjects) .where(and(eq(calendarObjects.calendarId, calendarId), inArray(calendarObjects.componentType, componentTypes))) .all(); }