Skip to content
File

Blob: src/worker/db/cal-dav-do/repository.ts

typescript378 lines
1import { and, desc, eq, gt, inArray, sql } from "drizzle-orm";
2 
3import { randomHex } from "@/worker/auth/bytes";
4import { calendarObjectMatchesFilters } from "@/worker/caldav/ical/search";
5import type { CalendarSearchFilter, ParsedICalendar, RecurrenceBounds } from "@/worker/caldav/ical/types";
6import { calendarHref as calHref, calendarObjectHref as objectHref } from "@/worker/caldav/paths";
7import {
8 davSyncToken,
9 objectNameFromCollectionHref,
10 parseDavSyncToken,
11 parseDavSyncTokenStrict,
12 validDavCollectionName,
13} from "@/worker/db/dav-collections";
14import type { CalDavDoDb } from "@/worker/db/cal-dav-do/client";
15import {
16 calendarChanges,
17 calendarDeadProps,
18 calendarIndex,
19 calendarObjects,
20 calendars,
21 calMeta,
22 type CalendarDeadPropRow,
23 type CalendarIndexRow,
24 type CalendarObjectRow,
25 type CalendarRow,
26} from "@/worker/db/cal-dav-do/schema";
27import type { DavChangeType } from "@/worker/db/types";
28 
29export { createCalDavDoDb } from "./client";
30 
31export interface CalendarResource {
32 kind: "home" | "calendar" | "object";
33 href: string;
34 id: string;
35 syncToken?: string;
36 calendar?: CalendarRow;
37 object?: CalendarObjectRow;
38 index?: CalendarIndexRow;
39 deadProps: CalendarDeadPropRow[];
40}
41 
42function calendarId(): string {
43 return `cal_${randomHex(16)}`;
44}
45 
46export function calendarObjectId(): string {
47 return `cobj_${randomHex(16)}`;
48}
49 
50export function validCalendarName(name: string): boolean {
51 return validDavCollectionName(name);
52}
53 
54export function calendarSyncToken(seq: number): string {
55 return davSyncToken(seq);
56}
57 
58export function calendarIndexRow(
59 objectId: string,
60 calendarId: string,
61 parsed: ParsedICalendar,
62): typeof calendarIndex.$inferInsert {
63 return {
64 objectId,
65 calendarId,
66 uid: parsed.uid,
67 componentType: parsed.componentType,
68 dtstartMs: parsed.dtstartMs,
69 dtendMs: parsed.dtendMs,
70 dueMs: parsed.dueMs,
71 completedMs: parsed.completedMs,
72 summary: parsed.summary,
73 hasRecurrence: parsed.hasRecurrence,
74 recurrenceMinMs: parsed.recurrenceMinMs,
75 recurrenceMaxMs: parsed.recurrenceMaxMs,
76 };
77}
78 
79export function ensureDefaultCalendar(db: CalDavDoDb, subjectId: string, nowMs: number): CalendarRow {
80 const existing = calendarByName(db, "default");
81 if (existing) return existing;
82 db.insert(calMeta).values({ key: "subject_id", value: subjectId, updatedAtMs: nowMs }).onConflictDoNothing().run();
83 db.insert(calendars)
84 .values({
85 id: calendarId(),
86 name: "default",
87 displayName: "Default",
88 description: null,
89 timezoneIcal: null,
90 color: null,
91 orderIndex: 0,
92 createdAtMs: nowMs,
93 modifiedAtMs: nowMs,
94 syncSeq: 0,
95 })
96 .run();
97 return calendarByName(db, "default")!;
98}
99 
100export function ensureCalendar(
101 db: CalDavDoDb,
102 subjectId: string,
103 nowMs: number,
104 calendarName: string,
105): CalendarRow | null {
106 if (calendarName === "default") return ensureDefaultCalendar(db, subjectId, nowMs);
107 return calendarByName(db, calendarName) ?? null;
108}
109 
110export function listCalendars(db: CalDavDoDb, subjectId: string, nowMs: number): CalendarRow[] {
111 ensureDefaultCalendar(db, subjectId, nowMs);
112 return db.select().from(calendars).orderBy(calendars.orderIndex, calendars.createdAtMs).all();
113}
114 
115export function calendarById(db: CalDavDoDb, id: string): CalendarRow | undefined {
116 return db.query.calendars.findFirst({ where: eq(calendars.id, id) }).sync();
117}
118 
119export function calendarByName(db: CalDavDoDb, name: string): CalendarRow | undefined {
120 return db.query.calendars.findFirst({ where: eq(calendars.name, name) }).sync();
121}
122 
123export function createCalendar(
124 db: CalDavDoDb,
125 input: {
126 subjectId: string;
127 name: string;
128 displayName: string;
129 description: string | null;
130 color: string | null;
131 orderIndex: number;
132 timezoneIcal: string | null;
133 nowMs: number;
134 },
135): CalendarRow {
136 ensureDefaultCalendar(db, input.subjectId, input.nowMs);
137 const id = calendarId();
138 db.insert(calendars)
139 .values({
140 id,
141 name: input.name,
142 displayName: input.displayName,
143 description: input.description,
144 timezoneIcal: input.timezoneIcal,
145 color: input.color,
146 orderIndex: input.orderIndex,
147 createdAtMs: input.nowMs,
148 modifiedAtMs: input.nowMs,
149 syncSeq: 0,
150 })
151 .run();
152 return calendarById(db, id)!;
153}
154 
155export function updateCalendar(
156 db: CalDavDoDb,
157 input: {
158 id: string;
159 displayName?: string;
160 description?: string | null;
161 color?: string | null;
162 orderIndex?: number;
163 timezoneIcal?: string | null;
164 nowMs: number;
165 },
166): CalendarRow | undefined {
167 const existing = calendarById(db, input.id);
168 if (!existing) return undefined;
169 db.update(calendars)
170 .set({
171 displayName: input.displayName ?? existing.displayName,
172 description: input.description === undefined ? existing.description : input.description,
173 color: input.color === undefined ? existing.color : input.color,
174 orderIndex: input.orderIndex ?? existing.orderIndex,
175 timezoneIcal: input.timezoneIcal === undefined ? existing.timezoneIcal : input.timezoneIcal,
176 modifiedAtMs: input.nowMs,
177 syncSeq: sql`${calendars.syncSeq} + 1`,
178 })
179 .where(eq(calendars.id, input.id))
180 .run();
181 return calendarById(db, input.id);
182}
183 
184export function deleteCalendar(db: CalDavDoDb, id: string): boolean {
185 const existing = calendarById(db, id);
186 if (!existing) return false;
187 const objectIds = db
188 .select({ id: calendarObjects.id })
189 .from(calendarObjects)
190 .where(eq(calendarObjects.calendarId, id))
191 .all();
192 db.transaction((tx) => {
193 tx.delete(calendarDeadProps)
194 .where(and(eq(calendarDeadProps.resourceKind, "calendar"), eq(calendarDeadProps.resourceId, id)))
195 .run();
196 if (objectIds.length > 0) {
197 tx.delete(calendarDeadProps)
198 .where(
199 and(
200 eq(calendarDeadProps.resourceKind, "object"),
201 inArray(
202 calendarDeadProps.resourceId,
203 objectIds.map((row) => row.id),
204 ),
205 ),
206 )
207 .run();
208 }
209 tx.delete(calendars).where(eq(calendars.id, id)).run();
210 });
211 return true;
212}
213 
214export function homeResource(db: CalDavDoDb): CalendarResource {
215 return { kind: "home", href: "/calendars/", id: "home", deadProps: deadProps(db, "home", "home") };
216}
217 
218export function calendarResource(db: CalDavDoDb, calendar: CalendarRow): CalendarResource {
219 return {
220 kind: "calendar",
221 href: calHref(calendar.name),
222 id: calendar.id,
223 syncToken: calendarSyncToken(latestChangeSeq(db)),
224 calendar,
225 deadProps: deadProps(db, "calendar", calendar.id),
226 };
227}
228 
229export function objectResource(db: CalDavDoDb, calendar: CalendarRow, object: CalendarObjectRow): CalendarResource {
230 return {
231 kind: "object",
232 href: objectHref(calendar.name, object.name),
233 id: object.id,
234 calendar,
235 object,
236 index: db.query.calendarIndex.findFirst({ where: eq(calendarIndex.objectId, object.id) }).sync(),
237 deadProps: deadProps(db, "object", object.id),
238 };
239}
240 
241export interface ObjectResourcesResult {
242 resources: CalendarResource[];
243 truncated: boolean;
244}
245 
246export function objectResources(
247 db: CalDavDoDb,
248 calendar: CalendarRow,
249 maxResults: number,
250 filters: CalendarSearchFilter[] = [],
251 recurrenceBounds?: RecurrenceBounds,
252): ObjectResourcesResult {
253 const { rows, truncated } = filteredObjects(db, calendar.id, maxResults, filters, recurrenceBounds);
254 return { resources: rows.map((row) => objectResource(db, calendar, row)), truncated };
255}
256 
257export function objectByName(db: CalDavDoDb, calendarId: string, name: string): CalendarObjectRow | undefined {
258 return db.query.calendarObjects
259 .findFirst({ where: and(eq(calendarObjects.calendarId, calendarId), eq(calendarObjects.name, name)) })
260 .sync();
261}
262 
263export function objectByUid(db: CalDavDoDb, calendarId: string, uid: string): CalendarObjectRow | undefined {
264 return db.query.calendarObjects
265 .findFirst({ where: and(eq(calendarObjects.calendarId, calendarId), eq(calendarObjects.uid, uid)) })
266 .sync();
267}
268 
269export function objectNameFromHref(calendar: CalendarRow, href: string, acceptedOrigins?: string[]): string | null {
270 return objectNameFromCollectionHref(calHref(calendar.name), href, acceptedOrigins);
271}
272 
273export function objectResourceFromHref(
274 db: CalDavDoDb,
275 calendar: CalendarRow,
276 href: string,
277 acceptedOrigins?: string[],
278): CalendarResource | null {
279 const name = objectNameFromHref(calendar, href, acceptedOrigins);
280 if (!name) return null;
281 const object = objectByName(db, calendar.id, name);
282 return object ? objectResource(db, calendar, object) : null;
283}
284 
285export function latestChangeSeq(db: CalDavDoDb): number {
286 return (
287 db.select({ seq: calendarChanges.seq }).from(calendarChanges).orderBy(desc(calendarChanges.seq)).limit(1).all()[0]
288 ?.seq ?? 0
289 );
290}
291 
292export interface ChangesSinceResult {
293 changes: { seq: number; calendarId: string | null; href: string; changeType: DavChangeType; changedAtMs: number }[];
294 truncated: boolean;
295}
296 
297export function changesSince(
298 db: CalDavDoDb,
299 calendar: CalendarRow,
300 token: string | null,
301 maxResults: number,
302): ChangesSinceResult {
303 const since = parseDavSyncToken(token);
304 const rows = db
305 .select()
306 .from(calendarChanges)
307 .where(and(eq(calendarChanges.calendarId, calendar.id), gt(calendarChanges.seq, since)))
308 .orderBy(calendarChanges.seq)
309 .limit(maxResults + 1)
310 .all();
311 const truncated = rows.length > maxResults;
312 return { changes: truncated ? rows.slice(0, maxResults) : rows, truncated };
313}
314 
315export function syncTokenSeq(token: string | null): number | null {
316 return parseDavSyncTokenStrict(token);
317}
318 
319export function recordChange(
320 db: CalDavDoDb,
321 calendarId: string,
322 href: string,
323 changeType: DavChangeType,
324 nowMs: number,
325): void {
326 db.insert(calendarChanges).values({ calendarId, href, changeType, changedAtMs: nowMs }).run();
327 db.update(calendars)
328 .set({ modifiedAtMs: nowMs, syncSeq: sql`${calendars.syncSeq} + 1` })
329 .where(eq(calendars.id, calendarId))
330 .run();
331}
332 
333function deadProps(db: CalDavDoDb, kind: "home" | "calendar" | "object", resourceId: string): CalendarDeadPropRow[] {
334 return db
335 .select()
336 .from(calendarDeadProps)
337 .where(and(eq(calendarDeadProps.resourceKind, kind), eq(calendarDeadProps.resourceId, resourceId)))
338 .all();
339}
340 
341function filteredObjects(
342 db: CalDavDoDb,
343 calendarId: string,
344 maxResults: number,
345 filters: CalendarSearchFilter[],
346 recurrenceBounds?: RecurrenceBounds,
347): { rows: CalendarObjectRow[]; truncated: boolean } {
348 if (filters.length === 0) {
349 const rows = db
350 .select()
351 .from(calendarObjects)
352 .where(eq(calendarObjects.calendarId, calendarId))
353 .limit(maxResults + 1)
354 .all();
355 const truncated = rows.length > maxResults;
356 return { rows: truncated ? rows.slice(0, maxResults) : rows, truncated };
357 }
358 const bounds = recurrenceBounds ?? { maxYears: 5, maxInstances: 10_000 };
359 const matched = candidateObjects(db, calendarId, filters).filter((row) =>
360 calendarObjectMatchesFilters(row.body, filters, bounds),
361 );
362 const truncated = matched.length > maxResults;
363 return { rows: matched.slice(0, maxResults), truncated };
364}
365 
366function candidateObjects(db: CalDavDoDb, calendarId: string, filters: CalendarSearchFilter[]): CalendarObjectRow[] {
367 const componentFilters = filters.filter((filter) => filter.kind === "component").map((filter) => filter.component);
368 if (componentFilters.length === 0) {
369 return db.select().from(calendarObjects).where(eq(calendarObjects.calendarId, calendarId)).all();
370 }
371 const componentTypes = [...new Set(componentFilters)];
372 return db
373 .select()
374 .from(calendarObjects)
375 .where(and(eq(calendarObjects.calendarId, calendarId), inArray(calendarObjects.componentType, componentTypes)))
376 .all();
377}