Files
familysync/apps/api/src/broker/sync.ts
T
Lucas Berger 1cc0d7278a feat(11-03): sync.ts derives reminderLeadMinutes from VALARM classification (D-07/NOTIF-05)
- Import classifyValarms from vevent.ts
- Derive reminderLeadMinutesValue: preset/offlist → leadMinutes; custom/none → null
- Add reminderLeadMinutes to .values() and .onDuplicateKeyUpdate({ set: {} })
- Scheduler now has ground truth for native-client VALARMs (T-11-07 mitigated)
2026-06-13 22:18:46 -04:00

306 lines
13 KiB
TypeScript

/**
* CalDAV sync: REPORT → ical.js parse → MariaDB upsert.
*
* D-13 schema contract:
* - All-day events: dtstart_date (DATE string), dtstart_utc=null, all_day=true
* - Timed events: dtstart_utc (TIMESTAMP as JS Date), dtstart_date=null, all_day=false
* - NEVER coerce DATE to DATETIME (Pitfall #3)
* - Raw VEVENT blob stored verbatim — only server-returned objects cached (T-03-05)
*
* Idempotency: calendar_id + uid composite unique key → onDuplicateKeyUpdate.
* ctag/syncToken updated on every sync so the poller can skip unchanged calendars.
*
* Sources:
* - https://github.com/natelindev/tsdav (fetchCalendarObjects)
* - https://github.com/kewisch/ical.js (ICAL.parse, ICAL.Component, ICAL.Time.isDate)
*/
import type { DAVCalendar } from 'tsdav';
import type { FastmailClient } from './client.js';
import ICAL from 'ical.js';
import { and, eq, notInArray } from 'drizzle-orm';
import { db } from '../db/client.js';
import { calendars, calendarEvents } from '../db/schema.js';
import type { EventChange } from '../lib/eventChangeDispatcher.js';
import { classifyValarms } from './vevent.js';
/**
* Fetches all calendar objects for a given DAVCalendar, parses VEVENTs with ical.js,
* and upserts the results into the MariaDB cache.
*
* @param client - tsdav DAVClient (already authenticated)
* @param davCal - DAVCalendar object from fetchCalendars()
* @param userId - FamilySync user ID (from member_credentials row)
*/
export async function syncCalendar(
client: FastmailClient,
davCal: DAVCalendar,
userId: number,
onChanges?: (changes: EventChange[]) => void,
): Promise<void> {
// 1. Upsert the calendar collection row.
// ctag / syncToken: use ?? null defensively (Pitfall #6 — Fastmail may omit either)
await db
.insert(calendars)
.values({
userId,
url: davCal.url,
displayName: typeof davCal.displayName === 'string' ? davCal.displayName : null,
ctag: davCal.ctag ?? null,
syncToken: davCal.syncToken ?? null,
lastSyncedAt: new Date(),
})
.onDuplicateKeyUpdate({
set: {
ctag: davCal.ctag ?? null,
syncToken: davCal.syncToken ?? null,
lastSyncedAt: new Date(),
},
});
// 2. Select the calendar row to get its DB id (insertId is unreliable on ON DUPLICATE KEY UPDATE).
// BUG B: scope by (userId, url) — the same collection URL exists for both members
// (shared Fastmail account, D-16). A url-only lookup returned the OTHER member's
// row (lowest id), so events were cached under the wrong calendarId.
const [cal] = await db
.select()
.from(calendars)
.where(and(eq(calendars.userId, userId), eq(calendars.url, davCal.url)))
.limit(1);
if (!cal) {
// Should never happen — we just upserted it
throw new Error(
`syncCalendar: could not find calendar row for userId=${userId} url=${davCal.url}`,
);
}
// 3. Fetch all calendar objects (REPORT calendar-query).
const objects = await client.fetchCalendarObjects({ calendar: davCal });
// 4. Parse each VCALENDAR/VEVENT and upsert into calendar_events.
// Track every uid we see on the server so step 5 can prune cache rows that
// no longer exist on Fastmail (deletes — local or external).
// Also collect EventChange records for the onChanges callback (NOTIF-03).
const seenUids: string[] = [];
const changes: EventChange[] = [];
for (const obj of objects) {
if (!obj.data) continue;
let parsed: ReturnType<typeof ICAL.parse>;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ical.js parse() returns 'any'; result is only passed to ICAL.Component which accepts it
parsed = ICAL.parse(obj.data as string);
} catch {
// Malformed VCALENDAR — skip but do not crash the sync
continue;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() output is 'any'; ICAL.Component is the correct consumer of this value
const comp = new ICAL.Component(parsed);
const vevent = comp.getFirstSubcomponent('vevent');
if (!vevent) continue;
// ical.js getFirstPropertyValue returns a union type; cast to ICAL.Time for date handling
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time | null;
const uid = vevent.getFirstPropertyValue('uid') as string | null;
if (!uid) continue;
seenUids.push(uid);
// D-13 / Pitfall #3: isDate=true → DATE column; isDate=false → TIMESTAMP column
const allDay: boolean = dtstart?.isDate ?? false;
// Determine if this event is a recurring master (has RRULE or RDATE).
// Use ICAL.Event.isRecurring() for parity with expand.ts — it checks both properties.
const isRecurring: boolean = new ICAL.Event(vevent).isRecurring();
// dtstartDate: Drizzle's `date` column accepts a Date object or null.
// We convert the YYYY-MM-DD string from ical.js to a Date (at midnight UTC) so
// Drizzle serialises it correctly as a DATE without a time component.
const dtstartDateValue: Date | null =
allDay && dtstart ? new Date(dtstart.toString().slice(0, 10) + 'T00:00:00Z') : null;
const dtstartUtcValue: Date | null = !allDay && dtstart ? dtstart.toJSDate() : null;
// Extract SUMMARY → title (NOTIF-01 dependency; closes title column stub).
const titleValue: string | null =
(vevent.getFirstPropertyValue('summary') as string | null) ?? null;
// Extract LOCATION for meaningful-change detection (D-04).
const locationValue: string | null =
(vevent.getFirstPropertyValue('location') as string | null) ?? null;
// Phase 11: derive reminderLeadMinutes from the VALARM classification (D-07, NOTIF-05).
// classifyValarms wraps ICAL.parse in try/catch (T-11-07 mitigated).
// preset/offlist → single relative lead in minutes (scheduler ground truth).
// custom (absolute DATE-TIME or multiple VALARMs) → null (can't resolve a single lead).
// none → null (no VALARM present).
const alarmClass = classifyValarms(obj.data as string);
const reminderLeadMinutesValue: number | null =
alarmClass.kind === 'preset' || alarmClass.kind === 'offlist'
? alarmClass.leadMinutes
: null;
// NOTIF-03: look up the existing row so we can classify add vs update.
// One indexed lookup on (calendarId, uid) — cheap, covered by uniq_calendar_uid.
// D-13: this read is from MariaDB cache, not Fastmail.
let oldRow: typeof calendarEvents.$inferSelect | null = null;
if (onChanges) {
const existing = await db
.select()
.from(calendarEvents)
.where(and(eq(calendarEvents.calendarId, cal.id), eq(calendarEvents.uid, uid)))
.limit(1);
oldRow = existing[0] ?? null;
}
await db
.insert(calendarEvents)
.values({
calendarId: cal.id,
uid,
etag: obj.etag ?? null,
objectUrl: obj.url ?? null,
rawVevent: obj.data as string,
title: titleValue,
dtstartUtc: dtstartUtcValue,
dtstartDate: dtstartDateValue,
allDay,
hasRrule: isRecurring,
// Phase 11: ground-truth VALARM lead for the scheduler (D-07/NOTIF-05, T-11-07)
reminderLeadMinutes: reminderLeadMinutesValue,
})
.onDuplicateKeyUpdate({
set: {
etag: obj.etag ?? null,
objectUrl: obj.url ?? null,
rawVevent: obj.data as string,
title: titleValue,
dtstartUtc: dtstartUtcValue,
dtstartDate: dtstartDateValue,
allDay,
hasRrule: isRecurring,
// Phase 11: keep reminderLeadMinutes current on re-sync (native client may change VALARM)
reminderLeadMinutes: reminderLeadMinutesValue,
updatedAt: new Date(),
},
});
// Collect change record for onChanges callback (NOTIF-03).
if (onChanges) {
if (oldRow === null) {
// New event — always meaningful (added)
changes.push({
uid,
title: titleValue,
operation: 'create',
dtstartUtc: dtstartUtcValue,
allDay,
});
} else {
// Existing event — compute which meaningful fields changed (D-04)
const changedFields: string[] = [];
// Compare dtstartUtc (timed events)
const oldUtcMs = oldRow.dtstartUtc ? new Date(oldRow.dtstartUtc).getTime() : null;
const newUtcMs = dtstartUtcValue ? dtstartUtcValue.getTime() : null;
if (oldUtcMs !== newUtcMs) changedFields.push('dtstartUtc');
// Compare dtstartDate (all-day events) — compare ISO date string
const oldDateStr = oldRow.dtstartDate
? new Date(oldRow.dtstartDate).toISOString().slice(0, 10)
: null;
const newDateStr = dtstartDateValue ? dtstartDateValue.toISOString().slice(0, 10) : null;
if (oldDateStr !== newDateStr) changedFields.push('dtstartDate');
// Compare allDay flag
if ((oldRow.allDay ? true : false) !== allDay) changedFields.push('allDay');
// Compare title (SUMMARY)
const oldTitle = oldRow.title ?? null;
if (oldTitle !== titleValue) changedFields.push('title');
// Compare location — extract from old rawVevent for comparison
let oldLocation: string | null = null;
if (oldRow.rawVevent) {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() returns 'any'; ICAL.Component is the correct consumer of this value
const oldComp = new ICAL.Component(ICAL.parse(oldRow.rawVevent));
const oldVevent = oldComp.getFirstSubcomponent('vevent');
if (oldVevent) {
oldLocation = (oldVevent.getFirstPropertyValue('location') as string | null) ?? null;
}
} catch {
// Malformed old VEVENT — skip location comparison
}
}
if (oldLocation !== locationValue) changedFields.push('location');
if (changedFields.length > 0) {
changes.push({
uid,
title: titleValue,
operation: 'update',
changedFields,
dtstartUtc: dtstartUtcValue,
allDay,
});
}
}
}
}
// 5. Prune deletes: remove cached events for THIS calendar whose uid is no
// longer present on the server. Without this, a deleted event (local delete
// via the outbox, or an external delete in another client) lingers in
// calendar_events forever — GET /api/events keeps returning it and the UI
// shows a ghost event that "won't delete". Scoped to cal.id so it never
// touches another calendar or the other household member's rows (BUG B).
// When the server returns zero events, prune the whole calendar's cache.
// NOTIF-03: collect deleted uids for the onChanges callback.
//
// WR-04: pre-capture the rows to delete BEFORE the DB delete so we know what
// was removed, then push changes AFTER the delete completes. This ensures the
// onChanges payload only describes events that are truly gone from the cache —
// not events that may have been re-fetched in a concurrent poll.
let pendingDeleteRows: Array<{ uid: string; title: string | null }> = [];
if (onChanges && seenUids.length > 0) {
// Find cached uids that are about to be pruned so we can emit delete changes
pendingDeleteRows = await db
.select({ uid: calendarEvents.uid, title: calendarEvents.title })
.from(calendarEvents)
.where(and(eq(calendarEvents.calendarId, cal.id), notInArray(calendarEvents.uid, seenUids)));
} else if (onChanges) {
// NEW-WR-01: server returned zero events → entire calendar cache will be cleared.
// Capture ALL currently-cached rows before the delete so delete-change events
// are emitted for each one. Without this, bulk/clear deletions were silently dropped.
pendingDeleteRows = await db
.select({ uid: calendarEvents.uid, title: calendarEvents.title })
.from(calendarEvents)
.where(eq(calendarEvents.calendarId, cal.id));
}
if (seenUids.length > 0) {
await db
.delete(calendarEvents)
.where(and(eq(calendarEvents.calendarId, cal.id), notInArray(calendarEvents.uid, seenUids)));
} else {
await db.delete(calendarEvents).where(eq(calendarEvents.calendarId, cal.id));
}
// Collect delete changes AFTER the DB delete (WR-04: avoids race where the same
// uid is re-inserted by a concurrent poll before onChanges fires).
for (const row of pendingDeleteRows) {
changes.push({
uid: row.uid,
title: row.title ?? null,
operation: 'delete',
});
}
// 6. Fire onChanges callback if provided and there are changes (NOTIF-03).
// Fire-and-forget: sync correctness must not depend on push success.
if (onChanges && changes.length > 0) {
onChanges(changes);
}
}