style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
+60
-66
@@ -15,13 +15,13 @@
|
||||
* - 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 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';
|
||||
|
||||
/**
|
||||
* Fetches all calendar objects for a given DAVCalendar, parses VEVENTs with ical.js,
|
||||
@@ -55,7 +55,7 @@ export async function syncCalendar(
|
||||
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
|
||||
@@ -65,78 +65,80 @@ export async function syncCalendar(
|
||||
.select()
|
||||
.from(calendars)
|
||||
.where(and(eq(calendars.userId, userId), eq(calendars.url, davCal.url)))
|
||||
.limit(1)
|
||||
.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}`)
|
||||
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 })
|
||||
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[] = []
|
||||
const seenUids: string[] = [];
|
||||
const changes: EventChange[] = [];
|
||||
|
||||
for (const obj of objects) {
|
||||
if (!obj.data) continue
|
||||
if (!obj.data) continue;
|
||||
|
||||
let parsed: ReturnType<typeof ICAL.parse>
|
||||
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)
|
||||
parsed = ICAL.parse(obj.data as string);
|
||||
} catch {
|
||||
// Malformed VCALENDAR — skip but do not crash the sync
|
||||
continue
|
||||
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
|
||||
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)
|
||||
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
|
||||
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()
|
||||
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
|
||||
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
|
||||
(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
|
||||
(vevent.getFirstPropertyValue('location') as string | null) ?? 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
|
||||
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
|
||||
.limit(1);
|
||||
oldRow = existing[0] ?? null;
|
||||
}
|
||||
|
||||
await db
|
||||
@@ -165,7 +167,7 @@ export async function syncCalendar(
|
||||
hasRrule: isRecurring,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Collect change record for onChanges callback (NOTIF-03).
|
||||
if (onChanges) {
|
||||
@@ -177,48 +179,45 @@ export async function syncCalendar(
|
||||
operation: 'create',
|
||||
dtstartUtc: dtstartUtcValue,
|
||||
allDay,
|
||||
})
|
||||
});
|
||||
} else {
|
||||
// Existing event — compute which meaningful fields changed (D-04)
|
||||
const changedFields: string[] = []
|
||||
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')
|
||||
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')
|
||||
: 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')
|
||||
if ((oldRow.allDay ? true : false) !== allDay) changedFields.push('allDay');
|
||||
|
||||
// Compare title (SUMMARY)
|
||||
const oldTitle = oldRow.title ?? null
|
||||
if (oldTitle !== titleValue) changedFields.push('title')
|
||||
const oldTitle = oldRow.title ?? null;
|
||||
if (oldTitle !== titleValue) changedFields.push('title');
|
||||
|
||||
// Compare location — extract from old rawVevent for comparison
|
||||
let oldLocation: string | null = null
|
||||
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')
|
||||
const oldComp = new ICAL.Component(ICAL.parse(oldRow.rawVevent));
|
||||
const oldVevent = oldComp.getFirstSubcomponent('vevent');
|
||||
if (oldVevent) {
|
||||
oldLocation =
|
||||
(oldVevent.getFirstPropertyValue('location') as string | null) ?? null
|
||||
oldLocation = (oldVevent.getFirstPropertyValue('location') as string | null) ?? null;
|
||||
}
|
||||
} catch {
|
||||
// Malformed old VEVENT — skip location comparison
|
||||
}
|
||||
}
|
||||
if (oldLocation !== locationValue) changedFields.push('location')
|
||||
if (oldLocation !== locationValue) changedFields.push('location');
|
||||
|
||||
if (changedFields.length > 0) {
|
||||
changes.push({
|
||||
@@ -228,7 +227,7 @@ export async function syncCalendar(
|
||||
changedFields,
|
||||
dtstartUtc: dtstartUtcValue,
|
||||
allDay,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,18 +246,13 @@ export async function syncCalendar(
|
||||
// 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 }> = []
|
||||
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),
|
||||
),
|
||||
)
|
||||
.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
|
||||
@@ -266,15 +260,15 @@ export async function syncCalendar(
|
||||
pendingDeleteRows = await db
|
||||
.select({ uid: calendarEvents.uid, title: calendarEvents.title })
|
||||
.from(calendarEvents)
|
||||
.where(eq(calendarEvents.calendarId, cal.id))
|
||||
.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)))
|
||||
.where(and(eq(calendarEvents.calendarId, cal.id), notInArray(calendarEvents.uid, seenUids)));
|
||||
} else {
|
||||
await db.delete(calendarEvents).where(eq(calendarEvents.calendarId, cal.id))
|
||||
await db.delete(calendarEvents).where(eq(calendarEvents.calendarId, cal.id));
|
||||
}
|
||||
|
||||
// Collect delete changes AFTER the DB delete (WR-04: avoids race where the same
|
||||
@@ -284,12 +278,12 @@ export async function syncCalendar(
|
||||
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)
|
||||
onChanges(changes);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user