- 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>
221 lines
7.2 KiB
TypeScript
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 },
|
|
});
|
|
}
|