feat(05-07): implement eventChangeDispatcher + syncCalendar diff/title/onChanges

- Create eventChangeDispatcher.ts: dispatchEventChange + isMeaningfulChange
- D-04: description-only edits are silent; meaningful fields = title/dtstartUtc/dtstartDate/allDay/location
- D-03: actor excluded via ne() + application-level filter; all subs filtered by userId != actorUserId
- D-13: reads only push_subscriptions from MariaDB — no tsdav/Fastmail I/O
- syncCalendar: add optional onChanges callback; populate title from VEVENT SUMMARY on every upsert
- syncCalendar: pre-upsert SELECT to detect add vs update; track changedFields; prune emits deletes
- poller: pass onChanges with actor=cred.userId (external changes from other member)
- outboxWorker.triggerTargetedResync: pass onChanges with actor=userId (this-member writes)
- All 4 eventChangeDispatcher tests + 14 sync tests GREEN
This commit is contained in:
Lucas Berger
2026-06-09 22:03:52 -04:00
parent 4ef6333201
commit 30e9de13f9
4 changed files with 310 additions and 2 deletions
+13 -1
View File
@@ -34,6 +34,7 @@ import { syncCalendar } from './sync.js'
import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from './write.js'
import { buildVeventString, extractRruleString, RRULE_PRESETS } from './vevent.js'
import type { FastmailClient } from './client.js'
import { dispatchEventChange } from '../lib/eventChangeDispatcher.js'
// ── Constants (D-07) ────────────────────────────────────────────────────────
@@ -168,7 +169,18 @@ async function triggerTargetedResync(
return
}
await syncCalendar(client, davCal, userId)
// NOTIF-03: pass onChanges so this-member writes push to the other member.
// actor = userId (the member who wrote via the outbox — D-03).
await syncCalendar(client, davCal, userId, (changes) => {
for (const change of changes) {
dispatchEventChange(change, userId).catch((err: unknown) => {
console.error(
'[outboxWorker] dispatchEventChange error:',
err instanceof Error ? err.message : String(err),
)
})
}
})
} catch (err) {
// Re-sync failure is non-fatal — log and continue (T-03-13)
console.error(
+13 -1
View File
@@ -22,6 +22,7 @@ import { memberCredentials, calendars } from '../db/schema.js'
import { decryptPassword } from './crypto.js'
import { createFastmailClient } from './client.js'
import { syncCalendar } from './sync.js'
import { dispatchEventChange } from '../lib/eventChangeDispatcher.js'
/**
* Runs one full poll cycle:
@@ -64,7 +65,18 @@ export async function runPoll(): Promise<void> {
continue
}
await syncCalendar(client, davCal, cred.userId)
// NOTIF-03: pass onChanges so external event changes push to non-actor members.
// actor = cred.userId (the member whose Fastmail poll detected the change — D-03).
await syncCalendar(client, davCal, cred.userId, (changes) => {
for (const change of changes) {
dispatchEventChange(change, cred.userId).catch((err: unknown) => {
console.error(
'[broker/poller] dispatchEventChange error:',
err instanceof Error ? err.message : String(err),
)
})
}
})
}
} catch (err) {
// Log the error but do NOT log the app password or key (T-03-04)
+120
View File
@@ -21,6 +21,7 @@ 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,
@@ -34,6 +35,7 @@ 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)
@@ -75,7 +77,10 @@ export async function syncCalendar(
// 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
@@ -111,6 +116,27 @@ export async function syncCalendar(
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
// 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({
@@ -119,6 +145,7 @@ export async function syncCalendar(
etag: obj.etag ?? null,
objectUrl: obj.url ?? null,
rawVevent: obj.data as string,
title: titleValue,
dtstartUtc: dtstartUtcValue,
dtstartDate: dtstartDateValue,
allDay,
@@ -129,6 +156,7 @@ export async function syncCalendar(
etag: obj.etag ?? null,
objectUrl: obj.url ?? null,
rawVevent: obj.data as string,
title: titleValue,
dtstartUtc: dtstartUtcValue,
dtstartDate: dtstartDateValue,
allDay,
@@ -136,6 +164,71 @@ export async function syncCalendar(
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) as string | null
if (oldTitle !== titleValue) changedFields.push('title')
// Compare location — extract from old rawVevent for comparison
let oldLocation: string | null = null
if (oldRow.rawVevent) {
try {
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
@@ -145,6 +238,27 @@ export async function syncCalendar(
// 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.
if (onChanges && seenUids.length > 0) {
// Find cached uids that are about to be pruned so we can emit delete changes
const cachedRows = await db
.select({ uid: calendarEvents.uid, title: calendarEvents.title })
.from(calendarEvents)
.where(
and(
eq(calendarEvents.calendarId, cal.id),
notInArray(calendarEvents.uid, seenUids),
),
)
for (const row of cachedRows) {
changes.push({
uid: row.uid,
title: row.title ?? null,
operation: 'delete',
})
}
}
if (seenUids.length > 0) {
await db
.delete(calendarEvents)
@@ -152,4 +266,10 @@ export async function syncCalendar(
} else {
await db.delete(calendarEvents).where(eq(calendarEvents.calendarId, cal.id))
}
// 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)
}
}