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:
Lucas Berger
2026-06-11 20:35:18 -04:00
parent 4bc0445173
commit 982438dc10
398 changed files with 19050 additions and 16382 deletions
+7 -4
View File
@@ -9,17 +9,20 @@
* (Pitfall #1: tsdav service discovery via /.well-known/caldav resolves to this principal)
*/
import { createDAVClient } from 'tsdav'
import { createDAVClient } from 'tsdav';
/** The resolved type of a tsdav client returned by createDAVClient. */
export type FastmailClient = Awaited<ReturnType<typeof createDAVClient>>
export type FastmailClient = Awaited<ReturnType<typeof createDAVClient>>;
/**
* Creates a tsdav DAVClient authenticated with Basic auth (app password).
* The returned client can call fetchCalendars() and fetchCalendarObjects().
* This call performs a service-discovery round-trip (Pitfall #3 — cache this client).
*/
export async function createFastmailClient(email: string, appPassword: string): Promise<FastmailClient> {
export async function createFastmailClient(
email: string,
appPassword: string,
): Promise<FastmailClient> {
return createDAVClient({
serverUrl: 'https://caldav.fastmail.com',
credentials: {
@@ -28,5 +31,5 @@ export async function createFastmailClient(email: string, appPassword: string):
},
authMethod: 'Basic',
defaultAccountType: 'caldav',
})
});
}
+20 -20
View File
@@ -11,27 +11,27 @@
* Source: Node.js docs node:crypto — AES-GCM
*/
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto'
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
// Validated at module load: key must be present and 32 bytes (64 hex chars).
// We deliberately do NOT throw here on missing key — that would crash the module
// during tests that set the env before the first import. The KEY is only read
// when encryptPassword / decryptPassword are actually called.
function getKey(): Buffer {
const hex = process.env.APP_PASSWORD_ENCRYPTION_KEY
const hex = process.env.APP_PASSWORD_ENCRYPTION_KEY;
if (!hex || hex.length !== 64) {
throw new Error(
'APP_PASSWORD_ENCRYPTION_KEY must be a 64-character hex string (32 bytes). ' +
'Generate with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"',
)
"Generate with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"",
);
}
return Buffer.from(hex, 'hex')
return Buffer.from(hex, 'hex');
}
interface EncryptedPayload {
iv: string
authTag: string
ciphertext: string
iv: string;
authTag: string;
ciphertext: string;
}
/**
@@ -40,17 +40,17 @@ interface EncryptedPayload {
* Each call generates a fresh random 96-bit IV.
*/
export function encryptPassword(plaintext: string): string {
const key = getKey()
const iv = randomBytes(12) // 96-bit IV for GCM
const cipher = createCipheriv('aes-256-gcm', key, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const authTag = cipher.getAuthTag()
const key = getKey();
const iv = randomBytes(12); // 96-bit IV for GCM
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
const payload: EncryptedPayload = {
iv: iv.toString('hex'),
authTag: authTag.toString('hex'),
ciphertext: encrypted.toString('hex'),
}
return JSON.stringify(payload)
};
return JSON.stringify(payload);
}
/**
@@ -58,12 +58,12 @@ export function encryptPassword(plaintext: string): string {
* Throws if the auth tag does not match (integrity violation).
*/
export function decryptPassword(stored: string): string {
const key = getKey()
const { iv, authTag, ciphertext } = JSON.parse(stored) as EncryptedPayload
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'hex'))
decipher.setAuthTag(Buffer.from(authTag, 'hex'))
const key = getKey();
const { iv, authTag, ciphertext } = JSON.parse(stored) as EncryptedPayload;
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'hex'));
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
return Buffer.concat([
decipher.update(Buffer.from(ciphertext, 'hex')),
decipher.final(),
]).toString('utf8')
]).toString('utf8');
}
+82 -85
View File
@@ -18,7 +18,7 @@
* - .planning/phases/02-calendar-display/02-RESEARCH.md §Pattern 1 + §Code Examples
*/
import ICAL from 'ical.js'
import ICAL from 'ical.js';
/**
* A concrete calendar event occurrence ready for UI consumption.
@@ -36,36 +36,36 @@ import ICAL from 'ical.js'
*/
export interface CalendarOccurrence {
/** `ev-<sanitized-uid>-<epochMs>` — Schedule-X-safe stable id (see makeOccurrenceId) */
id: string
uid: string
calendarId: number
calendarName: string
id: string;
uid: string;
calendarId: number;
calendarName: string;
/**
* The Fastmail user ID who owns the calendar this occurrence belongs to.
* Client routes Schedule-X calendarId as String(ownerUserId) for personal calendars.
*/
ownerUserId: number
ownerUserId: number;
/**
* Display name of the calendar owner (users.displayName).
* Null when the user has not set a display name.
* The popover uses this to show the owner name for personal events;
* falls back to calendarName when null.
*/
ownerName: string | null
ownerName: string | null;
/** Hex color: users.color for personal calendars, '#F25C7A' for shared-family */
color: string
color: string;
/** True when this occurrence belongs to the shared-family calendar (calendars.isShared=true) */
isShared: boolean
title: string
isShared: boolean;
title: string;
/** 'YYYY-MM-DD' for all-day events; IANA-annotated ISO string for timed events e.g. '2026-06-01T10:00:00-04:00[America/New_York]' */
start: string
start: string;
/** 'YYYY-MM-DD' for all-day events; IANA-annotated ISO string for timed events e.g. '2026-06-01T11:00:00-04:00[America/New_York]' */
end: string
allDay: boolean
location: string | null
description: string | null
end: string;
allDay: boolean;
location: string | null;
description: string | null;
/** True when this occurrence belongs to a recurring series (has RRULE). False for single events. */
hasRrule: boolean
hasRrule: boolean;
}
/**
@@ -73,14 +73,14 @@ export interface CalendarOccurrence {
* Carries color and ownership info from the calendars→users join.
*/
export interface OccurrenceMeta {
calendarId: number
calendarName: string
ownerUserId: number
calendarId: number;
calendarName: string;
ownerUserId: number;
/** Display name of the calendar owner; null when users.displayName is not set */
ownerName: string | null
ownerName: string | null;
/** Hex color: pre-computed by the route (users.color or shared-family constant) */
color: string
isShared: boolean
color: string;
isShared: boolean;
}
/**
@@ -99,9 +99,9 @@ export interface OccurrenceMeta {
* across refetches/windows, so Schedule-X dedup and the popover id lookup keep matching.
*/
function makeOccurrenceId(uid: string, start: ICAL.Time): string {
const safeUid = uid.replace(/[^A-Za-z0-9_-]/g, '_')
const epochMs = start.toJSDate().getTime()
return `ev-${safeUid}-${epochMs}`
const safeUid = uid.replace(/[^A-Za-z0-9_-]/g, '_');
const epochMs = start.toJSDate().getTime();
return `ev-${safeUid}-${epochMs}`;
}
/**
@@ -109,11 +109,11 @@ function makeOccurrenceId(uid: string, start: ICAL.Time): string {
* ICAL.Time.utcOffset() returns total seconds (positive = east of UTC).
*/
function formatUtcOffset(offsetSeconds: number): string {
const sign = offsetSeconds < 0 ? '-' : '+'
const abs = Math.abs(offsetSeconds)
const hours = Math.floor(abs / 3600)
const minutes = Math.floor((abs % 3600) / 60)
return `${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
const sign = offsetSeconds < 0 ? '-' : '+';
const abs = Math.abs(offsetSeconds);
const hours = Math.floor(abs / 3600);
const minutes = Math.floor((abs % 3600) / 60);
return `${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
}
/**
@@ -128,10 +128,10 @@ function formatUtcOffset(offsetSeconds: number): string {
function serializeTime(t: ICAL.Time, allDay: boolean): string {
if (allDay) {
// All-day: return DATE-only string — never construct a datetime (Pitfall 2)
const y = String(t.year)
const m = String(t.month).padStart(2, '0')
const d = String(t.day).padStart(2, '0')
return `${y}-${m}-${d}`
const y = String(t.year);
const m = String(t.month).padStart(2, '0');
const d = String(t.day).padStart(2, '0');
return `${y}-${m}-${d}`;
}
// Timed: build an IANA-annotated ISO string so the client can construct Temporal.ZonedDateTime.
@@ -139,22 +139,22 @@ function serializeTime(t: ICAL.Time, allDay: boolean): string {
// We always emit '...±HH:MM[IANA/Zone]' — the bracket is mandatory for ZonedDateTime.from().
if (t.zone === ICAL.Timezone.utcTimezone) {
// UTC zone: toString() produces '...Z'; strip the Z and emit +00:00[UTC].
const base = t.toString().replace(/Z$/, '') // e.g. '2026-03-01T10:00:00'
return `${base}+00:00[UTC]`
const base = t.toString().replace(/Z$/, ''); // e.g. '2026-03-01T10:00:00'
return `${base}+00:00[UTC]`;
}
const tzid = t.zone?.tzid
const tzid = t.zone?.tzid;
// Floating zone (tzid === 'floating') has no real timezone — fall back to UTC.
// This is a safe degradation: the event had no VTIMEZONE and no offset is knowable.
if (!tzid || tzid === 'floating') {
const base = t.toString() // no trailing Z for floating
return `${base}+00:00[UTC]`
const base = t.toString(); // no trailing Z for floating
return `${base}+00:00[UTC]`;
}
// Named IANA zone: combine base datetime + offset + IANA bracket.
const base = t.toString() // e.g. '2026-03-01T10:00:00'
const offsetSec = t.utcOffset()
return `${base}${formatUtcOffset(offsetSec)}[${tzid}]`
const base = t.toString(); // e.g. '2026-03-01T10:00:00'
const offsetSec = t.utcOffset();
return `${base}${formatUtcOffset(offsetSec)}[${tzid}]`;
}
/**
@@ -178,53 +178,50 @@ export function expandOccurrences(
isShared: boolean,
): CalendarOccurrence[] {
// --- 1. Parse VCALENDAR — return [] on malformed input (matches sync.ts pattern) ---
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(rawVevent)
parsed = ICAL.parse(rawVevent);
} catch {
return []
return [];
}
// 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 comp = new ICAL.Component(parsed);
// --- 2. Register VTIMEZONE components BEFORE constructing RecurExpansion (MANDATORY) ---
// Skipping this causes ical.js to fall back to UTC, producing ±1h DST errors (Pitfall 3).
for (const vtz of comp.getAllSubcomponents('vtimezone')) {
const tzid = vtz.getFirstPropertyValue('tzid') as string
const tzid = vtz.getFirstPropertyValue('tzid') as string;
if (tzid && !ICAL.TimezoneService.has(tzid)) {
// TimezoneService.register(timezone, name?) — first arg is the Timezone object
ICAL.TimezoneService.register(
new ICAL.Timezone({ component: vtz, tzid }),
tzid,
)
ICAL.TimezoneService.register(new ICAL.Timezone({ component: vtz, tzid }), tzid);
}
}
// --- 3. Get the VEVENT component ---
const vevent = comp.getFirstSubcomponent('vevent')
if (!vevent) return []
const vevent = comp.getFirstSubcomponent('vevent');
if (!vevent) return [];
const event = new ICAL.Event(vevent)
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time
if (!dtstart) return []
const event = new ICAL.Event(vevent);
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time;
if (!dtstart) return [];
const allDay: boolean = dtstart.isDate
const uid: string = event.uid ?? ''
const allDay: boolean = dtstart.isDate;
const uid: string = event.uid ?? '';
// useUTC=true: windowStart/windowEnd are absolute instants (midnight-UTC of the
// requested dates). Interpreting them in UTC keeps the occurrence-window comparison
// absolute. With useUTC=false ical.js used the SERVER's local timezone, shifting the
// window by the server's offset and dropping evening occurrences near the window end
// (e.g. a 17:45-04:00 event = 21:45Z fell past a day window whose end was shifted to 20:00).
const rangeStart = ICAL.Time.fromJSDate(windowStart, true)
const rangeEnd = ICAL.Time.fromJSDate(windowEnd, true)
const rangeStart = ICAL.Time.fromJSDate(windowStart, true);
const rangeEnd = ICAL.Time.fromJSDate(windowEnd, true);
const occurrences: CalendarOccurrence[] = []
const occurrences: CalendarOccurrence[] = [];
// Capture once — used in both the non-recurring and recurring branches to populate hasRrule.
const isRecurring = event.isRecurring()
const isRecurring = event.isRecurring();
// --- 4. Non-recurring event: single occurrence check ---
if (!isRecurring) {
@@ -232,19 +229,19 @@ export function expandOccurrences(
// Use ICAL.Event.endDate which derives end from DTEND, or DTSTART+DURATION, or sensible default.
// Do NOT use getFirstPropertyValue('dtend') directly — events with only DURATION set return null,
// producing zero-duration occurrences (BUG 1).
let occEnd: ICAL.Time = event.endDate ?? dtstart
let occEnd: ICAL.Time = event.endDate ?? dtstart;
// Positive-duration guard: ensure timed events have non-zero height in Schedule-X.
if (!allDay && occEnd.compare(dtstart) <= 0) {
occEnd = dtstart.clone()
occEnd.addDuration(ICAL.Duration.fromString('PT30M'))
occEnd = dtstart.clone();
occEnd.addDuration(ICAL.Duration.fromString('PT30M'));
} else if (allDay && occEnd.compare(dtstart) <= 0) {
occEnd = dtstart.clone()
occEnd.addDuration(ICAL.Duration.fromString('P1D'))
occEnd = dtstart.clone();
occEnd.addDuration(ICAL.Duration.fromString('P1D'));
}
const start = serializeTime(dtstart, allDay)
const end = serializeTime(occEnd, allDay)
const start = serializeTime(dtstart, allDay);
const end = serializeTime(occEnd, allDay);
occurrences.push({
id: makeOccurrenceId(uid, dtstart),
uid,
@@ -261,36 +258,36 @@ export function expandOccurrences(
location: event.location ?? null,
description: event.description ?? null,
hasRrule: isRecurring, // always false in the non-recurring branch
})
});
}
return occurrences
return occurrences;
}
// --- 5. Recurring event: use ICAL.RecurExpansion ---
// RecurExpansion handles RRULE + RDATE + EXDATE internally — no manual EXDATE filtering (A1).
// VTIMEZONE was registered in step 2 above, so DST occurrences get correct wall-clock time.
const expand = new ICAL.RecurExpansion({ component: vevent, dtstart })
const expand = new ICAL.RecurExpansion({ component: vevent, dtstart });
let next: ICAL.Time | null | undefined
let next: ICAL.Time | null | undefined;
while ((next = expand.next() as ICAL.Time | null | undefined) && next.compare(rangeEnd) < 0) {
if (next.compare(rangeStart) < 0) continue
if (next.compare(rangeStart) < 0) continue;
// Compute occurrence end from event duration
const duration = event.duration
let occEnd = next.clone()
occEnd.addDuration(duration)
const duration = event.duration;
let occEnd = next.clone();
occEnd.addDuration(duration);
// Positive-duration guard: same logic as non-recurring branch above.
if (!allDay && occEnd.compare(next) <= 0) {
occEnd = next.clone()
occEnd.addDuration(ICAL.Duration.fromString('PT30M'))
occEnd = next.clone();
occEnd.addDuration(ICAL.Duration.fromString('PT30M'));
} else if (allDay && occEnd.compare(next) <= 0) {
occEnd = next.clone()
occEnd.addDuration(ICAL.Duration.fromString('P1D'))
occEnd = next.clone();
occEnd.addDuration(ICAL.Duration.fromString('P1D'));
}
const start = serializeTime(next, allDay)
const end = serializeTime(occEnd, allDay)
const start = serializeTime(next, allDay);
const end = serializeTime(occEnd, allDay);
occurrences.push({
id: makeOccurrenceId(uid, next),
@@ -308,8 +305,8 @@ export function expandOccurrences(
location: event.location ?? null,
description: event.description ?? null,
hasRrule: isRecurring, // always true in the recurring branch
})
});
}
return occurrences
return occurrences;
}
+169 -154
View File
@@ -23,36 +23,36 @@
*
* Source: poller.ts pattern (runPoll/startBrokerPoller)
*/
import { z } from 'zod'
import { and, eq, lte } from 'drizzle-orm'
import { db } from '../db/client.js'
import { calendarEvents, calendarOutbox, calendars, memberCredentials } from '../db/schema.js'
import { createFastmailClient } from './client.js'
import { decryptPassword } from './crypto.js'
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'
import { z } from 'zod';
import { and, eq, lte } from 'drizzle-orm';
import { db } from '../db/client.js';
import { calendarEvents, calendarOutbox, calendars, memberCredentials } from '../db/schema.js';
import { createFastmailClient } from './client.js';
import { decryptPassword } from './crypto.js';
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) ────────────────────────────────────────────────────────
const MAX_ATTEMPTS = 5
const MAX_ATTEMPTS = 5;
/**
* Backoff delay in seconds per attempt index (0-based).
* Total window: 15+60+300+600+1800 ≈ 30 min.
*/
const BACKOFF_SECONDS = [15, 60, 300, 600, 1800]
const BACKOFF_SECONDS = [15, 60, 300, 600, 1800];
/** HTTP status codes treated as transient — retry with exponential backoff. */
const TRANSIENT_STATUSES = new Set([408, 429, 500, 502, 503, 504])
const TRANSIENT_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
/** HTTP status codes treated as hard failures — stop retry immediately. */
const HARD_FAIL_STATUSES = new Set([400, 401, 403])
const HARD_FAIL_STATUSES = new Set([400, 401, 403]);
/** HTTP status code for CalDAV If-Match conflict — D-08 conflict flow. */
const CONFLICT_STATUS = 412
const CONFLICT_STATUS = 412;
// ── Outbox payload re-validation (IN-03) ─────────────────────────────────────
@@ -83,12 +83,15 @@ const outboxPayloadSchema = z
// schema validates on ingress, but the outbox payload is re-parsed from stored JSON).
// Guarantees .replace(/-/g,'') in assembleRruleString emits digits-only, closing the
// RRULE-part injection vector. int().min(1) prevents zero/negative counts.
recurrenceUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), // 'YYYY-MM-DD' → RRULE UNTIL
recurrenceUntil: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(), // 'YYYY-MM-DD' → RRULE UNTIL
recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT
})
.passthrough()
.passthrough();
type OutboxPayloadFields = z.infer<typeof outboxPayloadSchema>
type OutboxPayloadFields = z.infer<typeof outboxPayloadSchema>;
// ── D-06: RRULE bound assembly ───────────────────────────────────────────────
@@ -120,21 +123,21 @@ export function assembleRruleString(
count?: number,
allDay?: boolean,
): string {
let s = basePreset
let s = basePreset;
if (count !== undefined) {
// COUNT wins over UNTIL (mutual exclusion)
s += `;COUNT=${count}`
s += `;COUNT=${count}`;
} else if (until) {
const dateDigits = until.replace(/-/g, '')
const dateDigits = until.replace(/-/g, '');
if (allDay) {
// DATE form for all-day events: YYYYMMDD (RFC 5545 §3.3.10)
s += `;UNTIL=${dateDigits}`
s += `;UNTIL=${dateDigits}`;
} else {
// DATETIME UTC form for timed events: YYYYMMDDTHHMMSSZ (end of UTC day)
s += `;UNTIL=${dateDigits}T235959Z`
s += `;UNTIL=${dateDigits}T235959Z`;
}
}
return s
return s;
}
// ── Drain concurrency guard (CR-05) ──────────────────────────────────────────
@@ -153,7 +156,7 @@ export function assembleRruleString(
* and only the process that wins the affected-rows check would dispatch the row.
* Do not remove this comment if deploying to multi-process infrastructure.
*/
let isDraining = false
let isDraining = false;
/**
* WR-06: max time to wait on the post-write targeted re-sync before marking the
@@ -161,7 +164,7 @@ let isDraining = false
* drain loop beyond this cap; the PWA's next sync-status poll reconciles any cache
* that the timed-out re-sync did not refresh.
*/
const RESYNC_TIMEOUT_MS = 10_000
const RESYNC_TIMEOUT_MS = 10_000;
// ── Credential + client loading ──────────────────────────────────────────────
@@ -175,19 +178,19 @@ async function loadClientForUser(userId: number): Promise<FastmailClient> {
const rows = await db
.select()
.from(memberCredentials)
.where(eq(memberCredentials.userId, userId))
.where(eq(memberCredentials.userId, userId));
// In production rows[0] is a real credential row.
// In unit tests the db mock returns the outbox row array (rows[0] is an outbox row) —
// that causes decryptPassword to throw, which is caught by the caller.
const cred = rows[0]
const cred = rows[0];
if (!cred) {
throw new Error(`No credential found for userId=${userId}`)
throw new Error(`No credential found for userId=${userId}`);
}
// T-03-13: decrypt only here; result never logged
const appPassword = decryptPassword(cred.encryptedPassword)
return createFastmailClient(cred.fastmailEmail, appPassword)
const appPassword = decryptPassword(cred.encryptedPassword);
return createFastmailClient(cred.fastmailEmail, appPassword);
}
// ── Targeted re-sync (D-06) ─────────────────────────────────────────────────
@@ -209,25 +212,24 @@ async function triggerTargetedResync(
): Promise<void> {
try {
// loadClientForUser may throw in test environments — caught below
let client = clientCache?.get(userId)
let client = clientCache?.get(userId);
if (!client) {
client = await loadClientForUser(userId)
clientCache?.set(userId, client)
client = await loadClientForUser(userId);
clientCache?.set(userId, client);
}
const davCalendars = await client.fetchCalendars()
const davCalendars = await client.fetchCalendars();
// Pitfall 7: find the DAVCalendar by URL match (normalize trailing slash differences)
const davCal = davCalendars.find(
(cal) =>
cal.url === calendarUrl ||
cal.url.replace(/\/$/, '') === calendarUrl.replace(/\/$/, ''),
)
cal.url === calendarUrl || cal.url.replace(/\/$/, '') === calendarUrl.replace(/\/$/, ''),
);
if (!davCal) {
console.error(
`[outboxWorker] DAVCalendar not found for url=${calendarUrl} — skipping re-sync`,
)
return
);
return;
}
// NOTIF-03: pass onChanges so this-member writes push to the other member.
@@ -238,38 +240,38 @@ async function triggerTargetedResync(
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(
'[outboxWorker] triggerTargetedResync error:',
err instanceof Error ? err.message : String(err),
)
);
}
}
// ── Row dispatch ─────────────────────────────────────────────────────────────
type OutboxRow = typeof calendarOutbox.$inferSelect
type OutboxRow = typeof calendarOutbox.$inferSelect;
interface DispatchResult {
success: boolean
conflict: boolean
hardFail: boolean
transient: boolean
error?: string
success: boolean;
conflict: boolean;
hardFail: boolean;
transient: boolean;
error?: string;
}
async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
// CR-03: fail closed on credential errors — let loadClientForUser throw.
// The outer per-row catch in runOutboxDrain logs and leaves the row pending (correct transient behavior).
// Do NOT add an empty-credential fallback — that would silently PUT with no authentication.
const client = await loadClientForUser(row.userId)
const client = await loadClientForUser(row.userId);
let response: Response
let response: Response;
if (row.operation === 'delete') {
if (!row.calendarObjectUrl) {
@@ -279,9 +281,9 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
hardFail: true,
transient: false,
error: 'delete operation missing calendarObjectUrl',
}
};
}
response = await deleteCalendarEvent(client, row.calendarObjectUrl, row.etag ?? null)
response = await deleteCalendarEvent(client, row.calendarObjectUrl, row.etag ?? null);
} else if (row.operation === 'update') {
if (!row.payload || !row.calendarObjectUrl) {
return {
@@ -290,22 +292,34 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
hardFail: true,
transient: false,
error: 'update operation missing payload or calendarObjectUrl',
}
};
}
// CR-02: parse the stored form JSON and build a real VCALENDAR string
let rawFields: Record<string, unknown>
let rawFields: Record<string, unknown>;
try {
rawFields = JSON.parse(row.payload) as Record<string, unknown>
rawFields = JSON.parse(row.payload) as Record<string, unknown>;
} catch {
return { success: false, conflict: false, hardFail: true, transient: false, error: 'payload parse failed' }
return {
success: false,
conflict: false,
hardFail: true,
transient: false,
error: 'payload parse failed',
};
}
// IN-03: re-validate the parsed payload. A schema-invalid row can never succeed —
// hard-fail it (no retry) rather than feeding undefined/Invalid Date into the VEVENT.
const parsedFields = outboxPayloadSchema.safeParse(rawFields)
const parsedFields = outboxPayloadSchema.safeParse(rawFields);
if (!parsedFields.success) {
return { success: false, conflict: false, hardFail: true, transient: false, error: `payload validation failed: ${parsedFields.error.message}` }
return {
success: false,
conflict: false,
hardFail: true,
transient: false,
error: `payload validation failed: ${parsedFields.error.message}`,
};
}
const fields: OutboxPayloadFields = parsedFields.data
const fields: OutboxPayloadFields = parsedFields.data;
// WR-01: recurrence preservation. The PWA omits `recurrence` from an edit payload
// (it cannot read the existing RRULE — not in the occurrence contract, D-03), so on
// update we must NOT rebuild the VEVENT with no RRULE — that would silently convert a
@@ -313,12 +327,12 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
// recurrence, fall back to the RRULE already stored in calendarEvents.rawVevent.
// An explicit recurrence value (including 'none') still overrides — that is a
// deliberate user change. Read rawVevent in the same scoped query as the fresh etag.
let preservedRrule: string | undefined
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence')
let preservedRrule: string | undefined;
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence');
const rruleFromPayload =
fields.recurrence && fields.recurrence !== 'none'
? RRULE_PRESETS[fields.recurrence as string]
: undefined
: undefined;
// WR-02: re-read the freshest etag from calendarEvents just before PUT.
// Rapid successive edits to the same uid enqueue multiple update rows, each
// carrying the etag at enqueue time. If a prior edit succeeded and triggered
@@ -334,7 +348,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
// D-08) or coincidentally match and overwrite. Scope the re-read to THIS row's
// own calendar by joining through calendars on the outbox row's userId +
// calendarUrl so the freshest etag belongs to the writing member.
let etagForPut: string | null = row.etag ?? null
let etagForPut: string | null = row.etag ?? null;
const freshEtagRows = (await db
.select({ etag: calendarEvents.etag, rawVevent: calendarEvents.rawVevent })
.from(calendarEvents)
@@ -346,15 +360,15 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
eq(calendars.url, row.calendarUrl),
),
)
.limit(1)) as Array<{ etag: string | null; rawVevent: string | null }>
.limit(1)) as Array<{ etag: string | null; rawVevent: string | null }>;
if (freshEtagRows.length > 0 && freshEtagRows[0].etag != null) {
etagForPut = freshEtagRows[0].etag
etagForPut = freshEtagRows[0].etag;
}
// WR-01: when the edit payload carries no explicit recurrence, preserve the RRULE
// already on the stored event so an edit does not strip a recurring series.
if (!hasExplicitRecurrence && freshEtagRows.length > 0 && freshEtagRows[0].rawVevent) {
preservedRrule = extractRruleString(freshEtagRows[0].rawVevent)
preservedRrule = extractRruleString(freshEtagRows[0].rawVevent);
}
// D-06: assemble the final RRULE string, combining the preset or preserved RRULE
@@ -364,7 +378,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
// concatenate onto `FREQ=WEEKLY;BYDAY=...` which would produce double-UNTIL.
// WR-01 note: preservedRrule is only set when !hasExplicitRecurrence (see above),
// so the hasExplicitRecurrence branch always takes precedence over preserved RRULE.
let finalRruleString: string | undefined
let finalRruleString: string | undefined;
if (hasExplicitRecurrence) {
// Explicit recurrence wins — recurrence:'none' yields undefined (no RRULE emitted)
finalRruleString = rruleFromPayload
@@ -374,22 +388,22 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
fields.recurrenceCount,
fields.allDay,
)
: undefined
: undefined;
} else if (preservedRrule) {
if (fields.recurrenceUntil || fields.recurrenceCount !== undefined) {
// Series edit with bound change only: strip existing UNTIL/COUNT, then re-apply
const strippedPreset = preservedRrule.replace(/;(UNTIL|COUNT)=[^;]*/g, '')
const strippedPreset = preservedRrule.replace(/;(UNTIL|COUNT)=[^;]*/g, '');
finalRruleString = assembleRruleString(
strippedPreset,
fields.recurrenceUntil,
fields.recurrenceCount,
fields.allDay,
)
);
} else {
finalRruleString = preservedRrule
finalRruleString = preservedRrule;
}
} else {
finalRruleString = rruleFromPayload
finalRruleString = rruleFromPayload;
}
const { icsString } = buildVeventString({
@@ -401,14 +415,9 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
location: fields.location,
description: fields.description,
rruleString: finalRruleString,
})
});
response = await updateCalendarEvent(
client,
row.calendarObjectUrl,
icsString,
etagForPut,
)
response = await updateCalendarEvent(client, row.calendarObjectUrl, icsString, etagForPut);
} else {
// create
if (!row.payload) {
@@ -418,21 +427,33 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
hardFail: true,
transient: false,
error: 'create operation missing payload',
}
};
}
// CR-02: parse the stored form JSON and build a real VCALENDAR string
let rawFields: Record<string, unknown>
let rawFields: Record<string, unknown>;
try {
rawFields = JSON.parse(row.payload) as Record<string, unknown>
rawFields = JSON.parse(row.payload) as Record<string, unknown>;
} catch {
return { success: false, conflict: false, hardFail: true, transient: false, error: 'payload parse failed' }
return {
success: false,
conflict: false,
hardFail: true,
transient: false,
error: 'payload parse failed',
};
}
// IN-03: re-validate the parsed payload — hard-fail a schema-invalid create row.
const parsedFields = outboxPayloadSchema.safeParse(rawFields)
const parsedFields = outboxPayloadSchema.safeParse(rawFields);
if (!parsedFields.success) {
return { success: false, conflict: false, hardFail: true, transient: false, error: `payload validation failed: ${parsedFields.error.message}` }
return {
success: false,
conflict: false,
hardFail: true,
transient: false,
error: `payload validation failed: ${parsedFields.error.message}`,
};
}
const fields: OutboxPayloadFields = parsedFields.data
const fields: OutboxPayloadFields = parsedFields.data;
// CR-01: edit-as-move RRULE preservation. The same-calendar `update` branch
// preserves a recurring series' RRULE by reading rawVevent; the `create` branch
// (used for the create half of an edit-as-move, D-04) has no source for the
@@ -441,21 +462,21 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
// the worker can re-apply it here. An explicit `recurrence` on the payload still
// wins (deliberate user change); the preserved RRULE only fills the gap when the
// edit omitted recurrence — matching the update-branch semantics and the WR-01 fix.
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence')
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence');
const rruleFromPayload =
fields.recurrence && fields.recurrence !== 'none'
? RRULE_PRESETS[fields.recurrence as string]
: undefined
: undefined;
const preservedRrule =
typeof fields._preservedRrule === 'string' && fields._preservedRrule.length > 0
? fields._preservedRrule
: undefined
: undefined;
// D-06: assemble the final RRULE string with optional UNTIL/COUNT bound.
// CR-01: an explicit recurrence preset wins over _preservedRrule (deliberate user choice).
// recurrence:'none' explicitly clears any RRULE — including when _preservedRrule is present.
// If no explicit recurrence, fall back to _preservedRrule (edit-as-move RRULE carry-through).
let finalRruleString: string | undefined
let finalRruleString: string | undefined;
if (hasExplicitRecurrence) {
// Explicit recurrence wins — recurrence:'none' yields undefined (no RRULE emitted)
finalRruleString = rruleFromPayload
@@ -465,22 +486,22 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
fields.recurrenceCount,
fields.allDay,
)
: undefined
: undefined;
} else if (preservedRrule) {
if (fields.recurrenceUntil || fields.recurrenceCount !== undefined) {
// Bound change on preserved RRULE: strip existing UNTIL/COUNT first (Pitfall 3)
const strippedPreset = preservedRrule.replace(/;(UNTIL|COUNT)=[^;]*/g, '')
const strippedPreset = preservedRrule.replace(/;(UNTIL|COUNT)=[^;]*/g, '');
finalRruleString = assembleRruleString(
strippedPreset,
fields.recurrenceUntil,
fields.recurrenceCount,
fields.allDay,
)
);
} else {
finalRruleString = preservedRrule
finalRruleString = preservedRrule;
}
} else {
finalRruleString = rruleFromPayload
finalRruleString = rruleFromPayload;
}
const { icsString } = buildVeventString({
@@ -492,13 +513,13 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
location: fields.location,
description: fields.description,
rruleString: finalRruleString,
})
});
// Build a minimal DAVCalendar for the write wrapper (only url is needed)
const davCalendar = { url: row.calendarUrl } as Parameters<typeof createCalendarEvent>[1]
response = await createCalendarEvent(client, davCalendar, row.uid, icsString)
const davCalendar = { url: row.calendarUrl } as Parameters<typeof createCalendarEvent>[1];
response = await createCalendarEvent(client, davCalendar, row.uid, icsString);
}
const status = response.status
const status = response.status;
if (status === CONFLICT_STATUS) {
return {
@@ -507,7 +528,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
hardFail: false,
transient: false,
error: `412 conflict: etag mismatch for uid=${row.uid}`,
}
};
}
if (HARD_FAIL_STATUSES.has(status)) {
@@ -517,7 +538,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
hardFail: true,
transient: false,
error: `Hard fail: HTTP ${status} for uid=${row.uid}`,
}
};
}
if (TRANSIENT_STATUSES.has(status)) {
@@ -527,11 +548,11 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
hardFail: false,
transient: true,
error: `Transient error: HTTP ${status} for uid=${row.uid}`,
}
};
}
if (response.ok) {
return { success: true, conflict: false, hardFail: false, transient: false }
return { success: true, conflict: false, hardFail: false, transient: false };
}
// IN-02: an unmapped 4xx (e.g. 405, 409, 422) is a permanent client error — retrying it
@@ -547,7 +568,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
hardFail: true,
transient: false,
error: `Hard fail: HTTP ${status} for uid=${row.uid}`,
}
};
}
// Unknown / 5xx status — treat as transient to avoid silent data loss
@@ -557,7 +578,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
hardFail: false,
transient: true,
error: `Unknown HTTP ${status} for uid=${row.uid}`,
}
};
}
// ── Main drain loop ──────────────────────────────────────────────────────────
@@ -580,8 +601,8 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
*/
export async function runOutboxDrain(): Promise<void> {
// CR-05: single-process concurrency guard (see isDraining declaration for limitations)
if (isDraining) return
isDraining = true
if (isDraining) return;
isDraining = true;
try {
// Fetch pending rows: WHERE status='pending' AND next_attempt_at <= NOW()
@@ -589,40 +610,37 @@ export async function runOutboxDrain(): Promise<void> {
.select()
.from(calendarOutbox)
.where(
and(
eq(calendarOutbox.status, 'pending'),
lte(calendarOutbox.nextAttemptAt, new Date()),
),
)) as OutboxRow[]
and(eq(calendarOutbox.status, 'pending'), lte(calendarOutbox.nextAttemptAt, new Date())),
)) as OutboxRow[];
if (pending.length === 0) return
if (pending.length === 0) return;
// D-04 edit-as-move ordering: sort so create rows come before delete rows within the same groupId.
// Rows without a groupId are unaffected (stable relative order preserved).
// This is a fast path; the authoritative gate is the durable DB sibling-status check below.
const sorted = [...pending].sort((a, b) => {
if (a.groupId && b.groupId && a.groupId === b.groupId) {
if (a.operation === 'create' && b.operation === 'delete') return -1
if (a.operation === 'delete' && b.operation === 'create') return 1
if (a.operation === 'create' && b.operation === 'delete') return -1;
if (a.operation === 'delete' && b.operation === 'create') return 1;
}
return 0
})
return 0;
});
// Track groupIds where the create failed within this batch (fast path for same-batch pairs).
// Cross-batch ordering is enforced durably by the DB sibling-status check inside the loop.
const failedCreateGroups = new Set<string>()
const failedCreateGroups = new Set<string>();
// IN-01: per-drain-cycle client cache so triggerTargetedResync decrypts each member's
// credential at most once per cycle. Discarded when the drain returns — never persisted.
const clientCache = new Map<number, FastmailClient>()
const clientCache = new Map<number, FastmailClient>();
for (const row of sorted) {
// D-04 fast path: if the create for this group already failed in this batch, skip the delete
if (row.operation === 'delete' && row.groupId && failedCreateGroups.has(row.groupId)) {
console.warn(
`[outboxWorker] Skipping delete row.id=${row.id} — create for groupId=${row.groupId} failed this batch (D-04)`,
)
continue
);
continue;
}
// CR-04: Durable create-before-delete gate — query DB for sibling create status.
@@ -632,40 +650,37 @@ export async function runOutboxDrain(): Promise<void> {
.select({ status: calendarOutbox.status })
.from(calendarOutbox)
.where(
and(
eq(calendarOutbox.groupId, row.groupId),
eq(calendarOutbox.operation, 'create'),
),
)) as Array<{ status: string }>
and(eq(calendarOutbox.groupId, row.groupId), eq(calendarOutbox.operation, 'create')),
)) as Array<{ status: string }>;
const siblingStatus = siblingRows[0]?.status
const siblingStatus = siblingRows[0]?.status;
if (siblingStatus !== 'done') {
if (siblingStatus === 'failed' || siblingStatus === 'dead') {
// Sibling create failed permanently — skip this delete forever (D-04: original preserved)
console.warn(
`[outboxWorker] Paired create for groupId=${row.groupId} is ${siblingStatus} — marking delete row.id=${row.id} failed (original event preserved, D-04)`,
)
);
await db
.update(calendarOutbox)
.set({
status: 'failed',
lastError: 'paired create did not succeed — original preserved',
})
.where(eq(calendarOutbox.id, row.id))
.where(eq(calendarOutbox.id, row.id));
} else {
// Sibling create is still pending/processing — defer this delete to a later cycle
console.warn(
`[outboxWorker] Deferring delete row.id=${row.id} — sibling create (groupId=${row.groupId}) is not yet done (status=${siblingStatus ?? 'not found'})`,
)
);
// Leave the delete row pending; do NOT update its status
}
continue
continue;
}
}
try {
const result = await dispatchRow(row)
const result = await dispatchRow(row);
if (result.conflict) {
// WR-06: distinguish an edit-as-move create-412 from a same-calendar conflict.
@@ -677,19 +692,19 @@ export async function runOutboxDrain(): Promise<void> {
// and has no cue to retry. Emit a move-specific lastError that does NOT contain
// '412' so the toast routes it to the dedicated move-failed copy instead of the
// generic etag-conflict copy.
const isMoveCreate = !!row.groupId && row.operation === 'create'
const isMoveCreate = !!row.groupId && row.operation === 'create';
const conflictError = isMoveCreate
? 'move-failed: the event could not be moved — re-open it and save again'
: (result.error ?? '412 conflict')
: (result.error ?? '412 conflict');
// 412 — mark failed (no retry), re-sync calendar so UI sees authoritative state (D-08)
await db
.update(calendarOutbox)
.set({ status: 'failed', lastError: conflictError })
.where(eq(calendarOutbox.id, row.id))
await triggerTargetedResync(row.calendarUrl, row.userId, clientCache)
.where(eq(calendarOutbox.id, row.id));
await triggerTargetedResync(row.calendarUrl, row.userId, clientCache);
if (row.groupId && row.operation === 'create') {
failedCreateGroups.add(row.groupId)
failedCreateGroups.add(row.groupId);
}
} else if (result.success) {
// Success — refresh the local cache BEFORE marking done. The PWA's
@@ -711,24 +726,24 @@ export async function runOutboxDrain(): Promise<void> {
await Promise.race([
triggerTargetedResync(row.calendarUrl, row.userId, clientCache),
new Promise<void>((resolve) => setTimeout(resolve, RESYNC_TIMEOUT_MS)),
])
]);
await db
.update(calendarOutbox)
.set({ status: 'done' })
.where(eq(calendarOutbox.id, row.id))
.where(eq(calendarOutbox.id, row.id));
} else if (result.hardFail) {
// Hard fail — mark failed immediately, no retry (D-07)
await db
.update(calendarOutbox)
.set({ status: 'failed', lastError: result.error ?? 'Hard fail' })
.where(eq(calendarOutbox.id, row.id))
.where(eq(calendarOutbox.id, row.id));
if (row.groupId && row.operation === 'create') {
failedCreateGroups.add(row.groupId)
failedCreateGroups.add(row.groupId);
}
} else {
// Transient — exponential backoff or dead-letter (D-07 / T-03-12)
const nextAttemptCount = row.attemptCount + 1
const nextAttemptCount = row.attemptCount + 1;
if (nextAttemptCount >= MAX_ATTEMPTS) {
// Dead-letter: max attempts reached (T-03-12)
await db
@@ -738,15 +753,15 @@ export async function runOutboxDrain(): Promise<void> {
attemptCount: nextAttemptCount,
lastError: result.error ?? 'Max attempts exceeded',
})
.where(eq(calendarOutbox.id, row.id))
.where(eq(calendarOutbox.id, row.id));
if (row.groupId && row.operation === 'create') {
failedCreateGroups.add(row.groupId)
failedCreateGroups.add(row.groupId);
}
} else {
// WR-01: use row.attemptCount (the attempt that just failed, 0-based) as the backoff index.
// This makes the first retry wait BACKOFF_SECONDS[0]=15s, not BACKOFF_SECONDS[1]=60s.
const backoffMs = (BACKOFF_SECONDS[row.attemptCount] ?? 1800) * 1000
const backoffMs = (BACKOFF_SECONDS[row.attemptCount] ?? 1800) * 1000;
await db
.update(calendarOutbox)
.set({
@@ -754,7 +769,7 @@ export async function runOutboxDrain(): Promise<void> {
nextAttemptAt: new Date(Date.now() + backoffMs),
lastError: result.error,
})
.where(eq(calendarOutbox.id, row.id))
.where(eq(calendarOutbox.id, row.id));
}
}
} catch (err) {
@@ -762,11 +777,11 @@ export async function runOutboxDrain(): Promise<void> {
console.error(
`[outboxWorker] Error dispatching row.id=${row.id} uid=${row.uid}:`,
err instanceof Error ? err.message : String(err),
)
);
}
}
} finally {
isDraining = false
isDraining = false;
}
}
@@ -781,7 +796,7 @@ export async function runOutboxDrain(): Promise<void> {
export function startOutboxWorker(): void {
setInterval(() => {
runOutboxDrain().catch((err: unknown) => {
console.error('[outboxWorker] Unhandled runOutboxDrain error:', err)
})
}, 15 * 1000)
console.error('[outboxWorker] Unhandled runOutboxDrain error:', err);
});
}, 15 * 1000);
}
+27 -24
View File
@@ -14,13 +14,13 @@
* (node-cron 4.2.1 silently skipped scheduled executions in the long-running server process;
* setInterval fires reliably in the same process — replaced to fix the silent skip.)
*/
import { and, eq } from 'drizzle-orm'
import { db } from '../db/client.js'
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'
import { and, eq } from 'drizzle-orm';
import { db } from '../db/client.js';
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:
@@ -32,15 +32,15 @@ import { dispatchEventChange } from '../lib/eventChangeDispatcher.js'
* so one bad credential does not stop processing for others.
*/
export async function runPoll(): Promise<void> {
const creds = await db.select().from(memberCredentials)
const creds = await db.select().from(memberCredentials);
for (const cred of creds) {
try {
// Decrypt before client creation (T-03-04: never expose decrypted value in logs)
const appPassword = decryptPassword(cred.encryptedPassword)
const appPassword = decryptPassword(cred.encryptedPassword);
const client = await createFastmailClient(cred.fastmailEmail, appPassword)
const davCalendars = await client.fetchCalendars()
const client = await createFastmailClient(cred.fastmailEmail, appPassword);
const davCalendars = await client.fetchCalendars();
for (const davCal of davCalendars) {
// Look up the stored calendar row to get the known ctag (D-13).
@@ -52,15 +52,15 @@ export async function runPoll(): Promise<void> {
.select()
.from(calendars)
.where(and(eq(calendars.userId, cred.userId), eq(calendars.url, davCal.url)))
.limit(1)
.limit(1);
// ctag/syncToken: defensive null handling (Pitfall #6)
const knownCtag = stored?.ctag ?? null
const currentCtag = davCal.ctag ?? davCal.syncToken ?? null
const knownCtag = stored?.ctag ?? null;
const currentCtag = davCal.ctag ?? davCal.syncToken ?? null;
// Skip if ctag is present on both sides and unchanged
if (currentCtag !== null && currentCtag === knownCtag) {
continue
continue;
}
// NOTIF-03: pass onChanges so external event changes push to non-actor members.
@@ -71,17 +71,17 @@ export async function runPoll(): Promise<void> {
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)
console.error(
`[broker/poller] Error processing credential id=${cred.id} (${cred.fastmailEmail}):`,
err instanceof Error ? err.message : String(err),
)
);
}
}
}
@@ -93,9 +93,12 @@ export async function runPoll(): Promise<void> {
* in the long-running server process; setInterval fires reliably.
*/
export function startBrokerPoller(): void {
setInterval(() => {
runPoll().catch((err: unknown) => {
console.error('[broker/poller] Unhandled runPoll error:', err)
})
}, 5 * 60 * 1000)
setInterval(
() => {
runPoll().catch((err: unknown) => {
console.error('[broker/poller] Unhandled runPoll error:', err);
});
},
5 * 60 * 1000,
);
}
+31 -31
View File
@@ -33,11 +33,11 @@
* T-05-19 — per-subscription try/catch; dispatchPush already swallows 410/404.
*/
import { and, eq, gt, lte, sql } from 'drizzle-orm'
import { db } from '../db/client.js'
import { calendars, calendarEvents, pushSubscriptions } from '../db/schema.js'
import { dispatchPush } from '../lib/pushDispatcher.js'
import type { NotificationPayload } from '../lib/pushDispatcher.js'
import { and, eq, gt, lte, sql } from 'drizzle-orm';
import { db } from '../db/client.js';
import { calendars, calendarEvents, pushSubscriptions } from '../db/schema.js';
import { dispatchPush } from '../lib/pushDispatcher.js';
import type { NotificationPayload } from '../lib/pushDispatcher.js';
// ── In-memory dedup (D-12: single-process, no Redis) ─────────────────────────
// Key: event uid (bare string — no minuteBucket suffix).
@@ -45,7 +45,7 @@ import type { NotificationPayload } from '../lib/pushDispatcher.js'
// Prevents double-fire when the same event sits in the catch-up window across
// multiple consecutive ticks (cross-tick exactly-once guarantee).
// Acceptable data loss on process restart for a two-person household.
const sentReminders = new Map<string, number>()
const sentReminders = new Map<string, number>();
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -53,10 +53,10 @@ const sentReminders = new Map<string, number>()
* Format a UTC Date as "YYYY-MM-DD" for calendar deep-link URLs.
*/
function yyyyMmDd(d: Date): string {
const y = d.getUTCFullYear()
const m = String(d.getUTCMonth() + 1).padStart(2, '0')
const day = String(d.getUTCDate()).padStart(2, '0')
return `${y}-${m}-${day}`
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
// ── Core scan ─────────────────────────────────────────────────────────────────
@@ -69,7 +69,7 @@ function yyyyMmDd(d: Date): string {
* default = new Date() (current wall-clock time).
*/
export async function runReminderCheck(now = new Date()): Promise<void> {
const windowEnd = new Date(now.getTime() + 16 * 60 * 1000)
const windowEnd = new Date(now.getTime() + 16 * 60 * 1000);
// Single query: events (isShared, timed, in catch-up window) cross-joined with ALL
// push_subscriptions. The cross-join (sql`1=1`) fans every matching event out
@@ -99,21 +99,21 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
.innerJoin(pushSubscriptions, sql`1=1`)
.where(
and(
eq(calendars.isShared, true), // D-05: shared calendar only (in QUERY, not copy)
eq(calendarEvents.allDay, false), // D-07: timed events only
gt(calendarEvents.dtstartUtc, now), // strictly future (excludes already-started)
eq(calendars.isShared, true), // D-05: shared calendar only (in QUERY, not copy)
eq(calendarEvents.allDay, false), // D-07: timed events only
gt(calendarEvents.dtstartUtc, now), // strictly future (excludes already-started)
lte(calendarEvents.dtstartUtc, windowEnd),
),
)
);
// Group flat (event, subscription) rows by event uid so we can:
// a) dedup per event (not per (event, sub) pair), and
// b) fan out to ALL subscriptions for a deduped event in one pass.
type SubRow = { id: number; userId: number; endpoint: string; p256dh: string; auth: string }
type SubRow = { id: number; userId: number; endpoint: string; p256dh: string; auth: string };
const byUid = new Map<
string,
{ uid: string; title: string | null; dtstartUtc: Date; subs: SubRow[] }
>()
>();
for (const row of rows) {
if (!byUid.has(row.uid)) {
@@ -123,7 +123,7 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
// dtstartUtc is guaranteed non-null by the allDay=false + gt/lte WHERE
dtstartUtc: row.dtstartUtc as Date,
subs: [],
})
});
}
// subId is undefined when cross-join produces no subscriptions row (empty table)
if (row.subId != null) {
@@ -133,7 +133,7 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
endpoint: row.subEndpoint,
p256dh: row.subP256dh,
auth: row.subAuth,
})
});
}
}
@@ -143,12 +143,12 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
// Per-uid exactly-once dedup (D-12): keyed on bare uid, no minuteBucket.
// Same event fires at most once regardless of how many ticks it sits in window.
// CAVEAT: rescheduling dtstart earlier after a reminder fired will not re-fire (v1).
if (sentReminders.has(uid)) continue
if (sentReminders.has(uid)) continue;
const dateStr = yyyyMmDd(event.dtstartUtc)
const dateStr = yyyyMmDd(event.dtstartUtc);
// Lead-accurate body: compute actual minutes to start, guarded to minimum 1.
const minutes = Math.max(1, Math.round((event.dtstartUtc.getTime() - now.getTime()) / 60000))
const minutes = Math.max(1, Math.round((event.dtstartUtc.getTime() - now.getTime()) / 60000));
const notification: NotificationPayload = {
// Null-safe title fallback (D-02 / NOTIF-01): title column may be NULL on rows
@@ -157,12 +157,12 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
body: `Starts in ${minutes} min`,
tag: `reminder-${uid}`,
navigate: `/calendar?date=${dateStr}&event=${uid}`,
}
};
// Fan out to every subscriber (member-count-agnostic)
for (const sub of event.subs) {
try {
await dispatchPush(sub, notification)
await dispatchPush(sub, notification);
} catch (err) {
// Per-subscription error isolation (T-05-19): one bad sub never aborts the cycle.
// dispatchPush itself never throws (it resolves after logging), so this outer
@@ -170,20 +170,20 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
console.error(
`[broker/reminderScheduler] Error dispatching reminder to sub id=${sub.id} for event uid=${uid}:`,
err instanceof Error ? err.message : String(err),
)
);
}
}
// WR-01: mark sent AFTER all dispatches have been attempted. Pre-marking before
// dispatch prevents retry when dispatchPush throws — at-least-once delivery
// requires not pre-marking the uid.
sentReminders.set(uid, event.dtstartUtc.getTime())
sentReminders.set(uid, event.dtstartUtc.getTime());
} catch (err) {
// Per-event error isolation (T-05-18): one bad event never aborts remaining events.
console.error(
`[broker/reminderScheduler] Error processing event uid=${uid}:`,
err instanceof Error ? err.message : String(err),
)
);
}
}
@@ -192,7 +192,7 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
// it can be removed safely (it will never re-enter the (now, now+16min] window).
for (const [uid, dtstartMs] of sentReminders) {
if (dtstartMs <= now.getTime()) {
sentReminders.delete(uid)
sentReminders.delete(uid);
}
}
}
@@ -209,7 +209,7 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
export function startReminderScheduler(): void {
setInterval(() => {
runReminderCheck().catch((err: unknown) => {
console.error('[broker/reminderScheduler] Unhandled runReminderCheck error:', err)
})
}, 60 * 1000)
console.error('[broker/reminderScheduler] Unhandled runReminderCheck error:', err);
});
}, 60 * 1000);
}
+29 -26
View File
@@ -24,49 +24,52 @@
* .planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md
*/
import { createFastmailClient } from './client.js'
import { createFastmailClient } from './client.js';
async function main() {
const email = process.env.FASTMAIL_EMAIL
const appPassword = process.env.FASTMAIL_APP_PASSWORD
const email = process.env.FASTMAIL_EMAIL;
const appPassword = process.env.FASTMAIL_APP_PASSWORD;
if (!email || !appPassword) {
console.error(
'Usage: FASTMAIL_EMAIL=<email> FASTMAIL_APP_PASSWORD=<pw> pnpm exec tsx src/broker/spike.ts',
)
process.exit(1)
);
process.exit(1);
}
console.log(`[CAL-08 spike] Connecting to Fastmail CalDAV as: ${email}`)
console.log('[CAL-08 spike] Creating tsdav client...')
console.log(`[CAL-08 spike] Connecting to Fastmail CalDAV as: ${email}`);
console.log('[CAL-08 spike] Creating tsdav client...');
const client = await createFastmailClient(email, appPassword)
const client = await createFastmailClient(email, appPassword);
console.log('[CAL-08 spike] Fetching calendars via PROPFIND...')
const calendars = await client.fetchCalendars()
console.log('[CAL-08 spike] Fetching calendars via PROPFIND...');
const calendars = await client.fetchCalendars();
console.log(`\n[CAL-08 spike] Found ${calendars.length} calendar collection(s):\n`)
console.log(`\n[CAL-08 spike] Found ${calendars.length} calendar collection(s):\n`);
for (const cal of calendars) {
console.log('---')
console.log(` url: ${cal.url}`)
console.log('---');
console.log(` url: ${cal.url}`);
// displayName may be a string or a Record (language-tagged value) per CalDAV spec
const displayName = typeof cal.displayName === 'string' ? cal.displayName : JSON.stringify(cal.displayName ?? '(none)')
console.log(` displayName: ${displayName}`)
const displayName =
typeof cal.displayName === 'string'
? cal.displayName
: JSON.stringify(cal.displayName ?? '(none)');
console.log(` displayName: ${displayName}`);
// ctag/syncToken: Fastmail may return either field (Pitfall #6)
console.log(` ctag: ${(cal as { ctag?: string }).ctag ?? '(not returned)'}`)
console.log(` syncToken: ${(cal as { syncToken?: string }).syncToken ?? '(not returned)'}`)
console.log(` ctag: ${(cal as { ctag?: string }).ctag ?? '(not returned)'}`);
console.log(` syncToken: ${(cal as { syncToken?: string }).syncToken ?? '(not returned)'}`);
}
console.log('\n[CAL-08 spike] Done.')
console.log('\nNext steps:')
console.log(' 1. Confirm the shared family calendar URL appears above.')
console.log(' 2. Confirm Lucas\'s personal calendar URL appears above.')
console.log(' 3. Record both URLs and ctag/syncToken findings in:')
console.log(' .planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md')
console.log('\n[CAL-08 spike] Done.');
console.log('\nNext steps:');
console.log(' 1. Confirm the shared family calendar URL appears above.');
console.log(" 2. Confirm Lucas's personal calendar URL appears above.");
console.log(' 3. Record both URLs and ctag/syncToken findings in:');
console.log(' .planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md');
}
main().catch((err: unknown) => {
console.error('[CAL-08 spike] Fatal error:', err instanceof Error ? err.message : String(err))
process.exit(1)
})
console.error('[CAL-08 spike] Fatal error:', err instanceof Error ? err.message : String(err));
process.exit(1);
});
+60 -66
View File
@@ -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);
}
}
+61 -57
View File
@@ -15,21 +15,21 @@
* - https://github.com/kewisch/ical.js/blob/main/lib/ical/time.js
*/
import ICAL from 'ical.js'
import { randomUUID } from 'crypto'
import ICAL from 'ical.js';
import { randomUUID } from 'crypto';
export interface NewEventParams {
uid?: string // omit = generate new UUID (appended with @familysync)
summary: string
allDay: boolean
uid?: string; // omit = generate new UUID (appended with @familysync)
summary: string;
allDay: boolean;
// All-day: YYYY-MM-DD string (or Date — only the date portion is used)
// Timed: JS Date representing a UTC instant
dtstart: string | Date
dtend: string | Date
location?: string
description?: string
rruleString?: string // e.g. 'FREQ=WEEKLY;BYDAY=MO' — omit for non-recurring (CAL-07)
dtstamp?: Date // omit = now()
dtstart: string | Date;
dtend: string | Date;
location?: string;
description?: string;
rruleString?: string; // e.g. 'FREQ=WEEKLY;BYDAY=MO' — omit for non-recurring (CAL-07)
dtstamp?: Date; // omit = now()
}
/**
@@ -51,7 +51,7 @@ export const RRULE_PRESETS: Record<string, string> = {
weekly: 'FREQ=WEEKLY',
monthly: 'FREQ=MONTHLY',
yearly: 'FREQ=YEARLY',
}
};
/**
* WR-01: Extract the existing RRULE string from a stored VCALENDAR/VEVENT, so the
@@ -61,21 +61,21 @@ export const RRULE_PRESETS: Record<string, string> = {
* the input cannot be parsed.
*/
export function extractRruleString(rawVevent: string): string | undefined {
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(rawVevent)
parsed = ICAL.parse(rawVevent);
} catch {
return undefined
return undefined;
}
// 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) return undefined
const rrule = vevent.getFirstPropertyValue('rrule')
if (!rrule) return undefined
const comp = new ICAL.Component(parsed);
const vevent = comp.getFirstSubcomponent('vevent');
if (!vevent) return undefined;
const rrule = vevent.getFirstPropertyValue('rrule');
if (!rrule) return undefined;
// ICAL.Recur#toString() yields the RECUR value, e.g. 'FREQ=WEEKLY'.
return typeof rrule === 'string' ? rrule : (rrule as ICAL.Recur).toString()
return typeof rrule === 'string' ? rrule : (rrule as ICAL.Recur).toString();
}
/**
@@ -85,57 +85,61 @@ export function extractRruleString(rawVevent: string): string | undefined {
* icsString is the full VCALENDAR string ready for PUT to Fastmail.
*/
export function buildVeventString(params: NewEventParams): { uid: string; icsString: string } {
const uid = params.uid ?? `${randomUUID()}@familysync`
const uid = params.uid ?? `${randomUUID()}@familysync`;
// --- VCALENDAR wrapper ---
const cal = new ICAL.Component(['vcalendar', [], []])
cal.updatePropertyWithValue('version', '2.0')
cal.updatePropertyWithValue('prodid', '-//FamilySync//FamilySync//EN')
const cal = new ICAL.Component(['vcalendar', [], []]);
cal.updatePropertyWithValue('version', '2.0');
cal.updatePropertyWithValue('prodid', '-//FamilySync//FamilySync//EN');
// --- VEVENT ---
const vevent = new ICAL.Component('vevent')
vevent.addPropertyWithValue('uid', uid)
vevent.addPropertyWithValue('summary', params.summary)
const vevent = new ICAL.Component('vevent');
vevent.addPropertyWithValue('uid', uid);
vevent.addPropertyWithValue('summary', params.summary);
// DTSTAMP: always present (RFC 5545 §3.8.7.2 — required property)
const dtstamp = ICAL.Time.fromJSDate(params.dtstamp ?? new Date(), true)
vevent.addPropertyWithValue('dtstamp', dtstamp)
const dtstamp = ICAL.Time.fromJSDate(params.dtstamp ?? new Date(), true);
vevent.addPropertyWithValue('dtstamp', dtstamp);
if (params.allDay) {
// DATE value (not DATETIME) — isDate:true produces VALUE=DATE, no time component (D-13 contract)
const startStr =
typeof params.dtstart === 'string'
? params.dtstart
: params.dtstart.toISOString().slice(0, 10)
: params.dtstart.toISOString().slice(0, 10);
const endStr =
typeof params.dtend === 'string'
? params.dtend
: params.dtend.toISOString().slice(0, 10)
typeof params.dtend === 'string' ? params.dtend : params.dtend.toISOString().slice(0, 10);
const [sy, sm, sd] = startStr.split('-').map(Number) as [number, number, number]
const [ey, em, ed] = endStr.split('-').map(Number) as [number, number, number]
const [sy, sm, sd] = startStr.split('-').map(Number) as [number, number, number];
const [ey, em, ed] = endStr.split('-').map(Number) as [number, number, number];
// WR-04 (owning boundary): RFC-5545 §3.6.1 — DTEND for an all-day event is the
// EXCLUSIVE end date. Advance the user-entered inclusive end by one calendar day.
// Building a Date from UTC components ensures no DST ambiguity during the roll-over.
const endDate = new Date(Date.UTC(ey, em - 1, ed))
endDate.setUTCDate(endDate.getUTCDate() + 1)
const ey2 = endDate.getUTCFullYear()
const em2 = endDate.getUTCMonth() + 1
const ed2 = endDate.getUTCDate()
const endDate = new Date(Date.UTC(ey, em - 1, ed));
endDate.setUTCDate(endDate.getUTCDate() + 1);
const ey2 = endDate.getUTCFullYear();
const em2 = endDate.getUTCMonth() + 1;
const ed2 = endDate.getUTCDate();
// ICAL.Timezone.localTimezone is passed as the zone arg required by TS types.
// isDate:true suppresses any time/TZID output regardless of zone. (D-13)
const startTime = new ICAL.Time({ year: sy, month: sm, day: sd, isDate: true }, ICAL.Timezone.localTimezone)
const endTime = new ICAL.Time({ year: ey2, month: em2, day: ed2, isDate: true }, ICAL.Timezone.localTimezone)
vevent.addPropertyWithValue('dtstart', startTime)
vevent.addPropertyWithValue('dtend', endTime)
const startTime = new ICAL.Time(
{ year: sy, month: sm, day: sd, isDate: true },
ICAL.Timezone.localTimezone,
);
const endTime = new ICAL.Time(
{ year: ey2, month: em2, day: ed2, isDate: true },
ICAL.Timezone.localTimezone,
);
vevent.addPropertyWithValue('dtstart', startTime);
vevent.addPropertyWithValue('dtend', endTime);
} else {
// DATETIME in UTC (useUTC=true → Z suffix; TZID is NOT added by ical.js) (D-13 contract)
const startTime = ICAL.Time.fromJSDate(params.dtstart as Date, true)
const endTime = ICAL.Time.fromJSDate(params.dtend as Date, true)
vevent.addPropertyWithValue('dtstart', startTime)
vevent.addPropertyWithValue('dtend', endTime)
const startTime = ICAL.Time.fromJSDate(params.dtstart as Date, true);
const endTime = ICAL.Time.fromJSDate(params.dtend as Date, true);
vevent.addPropertyWithValue('dtstart', startTime);
vevent.addPropertyWithValue('dtend', endTime);
}
// Optional: RRULE (CAL-07 — whole-series recurring events only in v1, D-11)
@@ -143,21 +147,21 @@ export function buildVeventString(params: NewEventParams): { uid: string; icsStr
// addPropertyWithValue('rrule', string) serializes the string character-by-character
// instead of as a RECUR value type.
if (params.rruleString) {
const recur = ICAL.Recur.fromString(params.rruleString)
const rruleProp = new ICAL.Property('rrule')
rruleProp.setValue(recur)
vevent.addProperty(rruleProp)
const recur = ICAL.Recur.fromString(params.rruleString);
const rruleProp = new ICAL.Property('rrule');
rruleProp.setValue(recur);
vevent.addProperty(rruleProp);
}
// Optional fields — included only when provided (no empty properties in ICS)
if (params.location) {
vevent.addPropertyWithValue('location', params.location)
vevent.addPropertyWithValue('location', params.location);
}
if (params.description) {
vevent.addPropertyWithValue('description', params.description)
vevent.addPropertyWithValue('description', params.description);
}
cal.addSubcomponent(vevent)
cal.addSubcomponent(vevent);
return { uid, icsString: cal.toString() }
return { uid, icsString: cal.toString() };
}
+7 -7
View File
@@ -24,8 +24,8 @@
* - https://github.com/natelindev/tsdav/blob/main/src/request.ts (If-Match confirmed)
*/
import type { FastmailClient } from './client.js'
import type { DAVCalendar } from 'tsdav'
import type { FastmailClient } from './client.js';
import type { DAVCalendar } from 'tsdav';
/**
* Creates a new CalDAV object via PUT with If-None-Match: * (create semantics).
@@ -46,7 +46,7 @@ export async function createCalendarEvent(
calendar,
filename: `${uid}.ics`,
iCalString: icsString,
})
});
}
/**
@@ -74,7 +74,7 @@ export async function updateCalendarEvent(
if (etag == null || etag === '') {
console.warn(
`[write] updateCalendarObject dispatching with NO If-Match (unconditional PUT) — conflict detection disabled for url=${calendarObjectUrl}`,
)
);
}
return client.updateCalendarObject({
calendarObject: {
@@ -82,7 +82,7 @@ export async function updateCalendarEvent(
data: icsString,
etag: etag ?? '', // tsdav maps etag → If-Match header; '' skips the header (safe default)
},
})
});
}
/**
@@ -103,7 +103,7 @@ export async function deleteCalendarEvent(
if (etag == null || etag === '') {
console.warn(
`[write] deleteCalendarObject dispatching with NO If-Match (unconditional DELETE) — conflict detection disabled for url=${calendarObjectUrl}`,
)
);
}
return client.deleteCalendarObject({
calendarObject: {
@@ -111,5 +111,5 @@ export async function deleteCalendarEvent(
data: '', // tsdav deleteCalendarObject requires the DAVCalendarObject shape; data unused
etag: etag ?? '',
},
})
});
}