My-Weekly-ToDo-List/src/lib/calendar-cache.ts
mARTin f6e158af8f fix: reduce iCloud CalDAV connections to prevent rate limiting
- Apple/Synology: batch fetch events with single client login instead of N separate connections
- Apple: add client cache (5min TTL) to reuse authenticated DAVClient across operations
- Apple: add retry with backoff for transient ConnectTimeoutError/SSL failures
- Increase cache stale threshold from 2min to 15min
- Increase background sync interval from 2min to 15min
- Throttle tab-focus sync to at most once per 5min

v1.75.2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 09:35:25 +01:00

221 lines
7.2 KiB
TypeScript

// @ts-nocheck
/* eslint-disable @typescript-eslint/no-explicit-any */
import { prisma } from './prisma';
import { getCalendarEvents, CalendarEvent, CalendarConnection } from './calendar-events';
const STALE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes — avoid iCloud rate limiting
/**
* Get the Monday (start of ISO week) for a given date.
*/
function getWeekStart(date: Date): Date {
const d = new Date(date);
d.setUTCHours(0, 0, 0, 0);
const day = d.getUTCDay(); // 0=Sun
const diff = day === 0 ? -6 : 1 - day;
d.setUTCDate(d.getUTCDate() + diff);
return d;
}
/**
* Check if the cache for a given connection + time window is stale.
*/
export async function isCacheStale(
connectionId: string,
timeMin: Date,
): Promise<boolean> {
const weekStart = getWeekStart(timeMin);
const newest = await prisma.cachedCalendarEvent.findFirst({
where: { connectionId, weekStart },
orderBy: { syncedAt: 'desc' },
select: { syncedAt: true },
});
if (!newest) return true;
return Date.now() - newest.syncedAt.getTime() > STALE_THRESHOLD_MS;
}
/**
* Read cached events for a user within a date range.
* Returns them in the flat shape the frontend expects.
*/
export async function readCachedEvents(
userId: string,
timeMin: Date,
timeMax: Date,
) {
const minDateStr = timeMin.toISOString().slice(0, 10);
const maxDateStr = timeMax.toISOString().slice(0, 10);
const rows = await prisma.cachedCalendarEvent.findMany({
where: {
userId,
OR: [
// Timed events within range
{
startDateTime: { gte: timeMin, lte: timeMax },
},
// All-day events within range
{
startDate: { gte: minDateStr, lte: maxDateStr },
},
],
},
orderBy: { startDateTime: 'asc' },
});
return rows.map(row => ({
id: row.externalId,
title: row.title,
description: row.description,
location: row.location,
url: row.url,
recurringEventId: row.recurringEventId,
isRecurring: row.isRecurring,
startTime: row.startDateTime?.toISOString() ?? row.startDate ?? '',
endTime: row.endDateTime?.toISOString() ?? row.endDate ?? '',
source: row.provider as 'google' | 'apple' | 'outlook' | 'synology',
calendarId: row.calendarId,
calendarTitle: row.calendarTitle,
calendarColor: row.calendarColor,
}));
}
export interface RefreshableConnection extends CalendarConnection {
dbId: string;
userId: string;
}
/**
* Fetch live events from provider, then atomic swap into cache.
*/
export async function refreshConnectionCache(
connection: RefreshableConnection,
timeMin: Date,
timeMax: Date,
): Promise<number> {
const weekStart = getWeekStart(timeMin);
let liveEvents: CalendarEvent[] = [];
try {
liveEvents = await getCalendarEvents(
[connection],
timeMin.toISOString(),
timeMax.toISOString(),
);
} catch (err) {
console.error(`[CACHE] Live fetch failed for connection ${connection.dbId}:`, err);
return 0;
}
const now = new Date();
const rows = liveEvents.map(ev => ({
userId: connection.userId,
externalId: ev.id,
connectionId: connection.dbId,
provider: connection.provider,
calendarId: ev.calendarId,
calendarTitle: ev.calendarTitle,
calendarColor: ev.backgroundColor ?? null,
title: ev.title,
description: ev.description ?? null,
location: ev.location ?? null,
url: ev.url ?? null,
recurringEventId: ev.recurringEventId ?? null,
isRecurring: ev.isRecurring ?? false,
startDateTime: ev.start.dateTime ? new Date(ev.start.dateTime) : null,
startDate: ev.start.date ?? null,
endDateTime: ev.end.dateTime ? new Date(ev.end.dateTime) : null,
endDate: ev.end.date ?? null,
reminders: ev.reminders ? JSON.parse(JSON.stringify(ev.reminders)) : null,
weekStart,
syncedAt: now,
}));
// Atomic swap: delete old events for this connection+week, insert fresh ones
await prisma.$transaction([
prisma.cachedCalendarEvent.deleteMany({
where: { connectionId: connection.dbId, weekStart },
}),
prisma.cachedCalendarEvent.createMany({
data: rows,
skipDuplicates: true,
}),
]);
console.log(`[CACHE] Refreshed ${rows.length} events for ${connection.provider} connection, week ${weekStart.toISOString().slice(0, 10)}`);
return rows.length;
}
/**
* Upsert a single event into cache (after create/update mutations).
*/
export async function upsertCachedEvent(
userId: string,
connectionId: string,
provider: string,
event: CalendarEvent,
): Promise<void> {
const weekStart = getWeekStart(
new Date(event.start.dateTime ?? event.start.date ?? Date.now()),
);
await prisma.cachedCalendarEvent.upsert({
where: {
userId_externalId_provider: { userId, externalId: event.id, provider },
},
create: {
userId,
externalId: event.id,
connectionId,
provider,
calendarId: event.calendarId,
calendarTitle: event.calendarTitle,
calendarColor: event.backgroundColor ?? null,
title: event.title,
description: event.description ?? null,
location: event.location ?? null,
url: event.url ?? null,
recurringEventId: event.recurringEventId ?? null,
isRecurring: event.isRecurring ?? false,
startDateTime: event.start.dateTime ? new Date(event.start.dateTime) : null,
startDate: event.start.date ?? null,
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
endDate: event.end.date ?? null,
reminders: event.reminders ? JSON.parse(JSON.stringify(event.reminders)) : null,
weekStart,
syncedAt: new Date(),
},
update: {
calendarTitle: event.calendarTitle,
calendarColor: event.backgroundColor ?? null,
title: event.title,
description: event.description ?? null,
location: event.location ?? null,
url: event.url ?? null,
recurringEventId: event.recurringEventId ?? null,
isRecurring: event.isRecurring ?? false,
startDateTime: event.start.dateTime ? new Date(event.start.dateTime) : null,
startDate: event.start.date ?? null,
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
endDate: event.end.date ?? null,
reminders: event.reminders ? JSON.parse(JSON.stringify(event.reminders)) : null,
weekStart,
syncedAt: new Date(),
},
});
}
/**
* Delete a single cached event (after delete mutations).
*/
export async function deleteCachedEvent(
userId: string,
externalId: string,
provider: string,
): Promise<void> {
await prisma.cachedCalendarEvent.deleteMany({
where: { userId, externalId, provider },
});
}