feat(06-02): serialize RRULE UNTIL/COUNT and lock FREQ persistence

- Add assembleRruleString() helper (exported) to outboxWorker.ts (D-06)
- Wire UNTIL/COUNT bound assembly into create + update dispatch branches
- Add recurrenceUntil (max 10) + recurrenceCount (int min 1) to outboxPayloadSchema
- Add recurrenceUntil + recurrenceCount to eventFieldsSchema in events.ts
- Series-edit bound change strips existing UNTIL/COUNT via regex before re-apply (Pitfall 3)
- All 39 broker tests pass (RED->GREEN); existing CR-01 none-wins test preserved
This commit is contained in:
Lucas Berger
2026-06-10 11:00:24 -04:00
parent a59455a727
commit d2abb91bd2
2 changed files with 125 additions and 2 deletions
+121 -2
View File
@@ -79,11 +79,62 @@ const outboxPayloadSchema = z
recurrence: z.enum(['none', 'daily', 'weekly', 'monthly', 'yearly']).optional(), recurrence: z.enum(['none', 'daily', 'weekly', 'monthly', 'yearly']).optional(),
calendarUrl: z.string().url().max(1024).optional(), calendarUrl: z.string().url().max(1024).optional(),
_preservedRrule: z.string().max(1024).optional(), _preservedRrule: z.string().max(1024).optional(),
// D-06: recurrence bounding (RRULE UNTIL / COUNT)
// T-06-02: max(10) bounds 'YYYY-MM-DD'; int().min(1) prevents zero/negative counts
recurrenceUntil: z.string().max(10).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 ───────────────────────────────────────────────
/**
* Assembles a complete RRULE string by appending a COUNT or UNTIL bound to a
* base preset string (e.g. 'FREQ=WEEKLY').
*
* Rules (RFC 5545 §3.3.10):
* - COUNT takes precedence over UNTIL when both are supplied (mutual exclusion).
* - For all-day events (allDay=true), UNTIL serializes as DATE form: `YYYYMMDD`.
* - For timed events (allDay=false), UNTIL serializes as DATETIME UTC: `YYYYMMDDTHHMMSSZ`.
* Using T235959Z (end of UTC day) is a safe universal choice per RESEARCH.md Pitfall 2.
* - When neither bound is supplied the base preset is returned unchanged.
*
* T-06-02 security note: `until.replace(/-/g,'')` emits only digits stripped from a
* length-bounded string (max 10 via Zod). The fixed `;UNTIL=` / `;COUNT=` templates
* prevent injection of extra `;`-delimited RRULE parts. The assembled string is later
* passed through `ICAL.Recur.fromString` which rejects malformed RRULE values.
*
* @param basePreset - Base RRULE string, e.g. 'FREQ=DAILY' (from RRULE_PRESETS or extracted)
* @param until - Optional 'YYYY-MM-DD' end date
* @param count - Optional occurrence count (integer ≥ 1)
* @param allDay - Whether the event is all-day (controls UNTIL value-type)
* @returns Assembled RRULE string
*/
export function assembleRruleString(
basePreset: string,
until?: string,
count?: number,
allDay?: boolean,
): string {
let s = basePreset
if (count !== undefined) {
// COUNT wins over UNTIL (mutual exclusion)
s += `;COUNT=${count}`
} else if (until) {
const dateDigits = until.replace(/-/g, '')
if (allDay) {
// DATE form for all-day events: YYYYMMDD (RFC 5545 §3.3.10)
s += `;UNTIL=${dateDigits}`
} else {
// DATETIME UTC form for timed events: YYYYMMDDTHHMMSSZ (end of UTC day)
s += `;UNTIL=${dateDigits}T235959Z`
}
}
return s
}
// ── Drain concurrency guard (CR-05) ────────────────────────────────────────── // ── Drain concurrency guard (CR-05) ──────────────────────────────────────────
/** /**
@@ -296,6 +347,41 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
preservedRrule = extractRruleString(freshEtagRows[0].rawVevent) preservedRrule = extractRruleString(freshEtagRows[0].rawVevent)
} }
// D-06: assemble the final RRULE string, combining the preset or preserved RRULE
// with an optional UNTIL/COUNT bound from the payload.
// Pitfall 3: on series edit with bound change only (no new preset), parse the preserved
// RRULE, STRIP any existing UNTIL/COUNT, then re-apply the new bound — never naive-
// 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
if (hasExplicitRecurrence) {
// Explicit recurrence wins — recurrence:'none' yields undefined (no RRULE emitted)
finalRruleString = rruleFromPayload
? assembleRruleString(
rruleFromPayload,
fields.recurrenceUntil,
fields.recurrenceCount,
fields.allDay as boolean,
)
: 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, '')
finalRruleString = assembleRruleString(
strippedPreset,
fields.recurrenceUntil,
fields.recurrenceCount,
fields.allDay as boolean,
)
} else {
finalRruleString = preservedRrule
}
} else {
finalRruleString = rruleFromPayload
}
const { icsString } = buildVeventString({ const { icsString } = buildVeventString({
uid: row.uid, uid: row.uid,
summary: fields.title as string, summary: fields.title as string,
@@ -304,7 +390,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
dtend: fields.allDay ? (fields.end as string) : new Date(fields.end as string), dtend: fields.allDay ? (fields.end as string) : new Date(fields.end as string),
location: fields.location as string | undefined, location: fields.location as string | undefined,
description: fields.description as string | undefined, description: fields.description as string | undefined,
rruleString: hasExplicitRecurrence ? rruleFromPayload : (preservedRrule ?? rruleFromPayload), rruleString: finalRruleString,
}) })
response = await updateCalendarEvent( response = await updateCalendarEvent(
@@ -354,6 +440,39 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
typeof fields._preservedRrule === 'string' && fields._preservedRrule.length > 0 typeof fields._preservedRrule === 'string' && fields._preservedRrule.length > 0
? fields._preservedRrule ? 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
if (hasExplicitRecurrence) {
// Explicit recurrence wins — recurrence:'none' yields undefined (no RRULE emitted)
finalRruleString = rruleFromPayload
? assembleRruleString(
rruleFromPayload,
fields.recurrenceUntil,
fields.recurrenceCount,
fields.allDay as boolean,
)
: 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, '')
finalRruleString = assembleRruleString(
strippedPreset,
fields.recurrenceUntil,
fields.recurrenceCount,
fields.allDay as boolean,
)
} else {
finalRruleString = preservedRrule
}
} else {
finalRruleString = rruleFromPayload
}
const { icsString } = buildVeventString({ const { icsString } = buildVeventString({
uid: row.uid, uid: row.uid,
summary: fields.title as string, summary: fields.title as string,
@@ -362,7 +481,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
dtend: fields.allDay ? (fields.end as string) : new Date(fields.end as string), dtend: fields.allDay ? (fields.end as string) : new Date(fields.end as string),
location: fields.location as string | undefined, location: fields.location as string | undefined,
description: fields.description as string | undefined, description: fields.description as string | undefined,
rruleString: hasExplicitRecurrence ? rruleFromPayload : (preservedRrule ?? rruleFromPayload), rruleString: finalRruleString,
}) })
// Build a minimal DAVCalendar for the write wrapper (only url is needed) // Build a minimal DAVCalendar for the write wrapper (only url is needed)
const davCalendar = { url: row.calendarUrl } as Parameters<typeof createCalendarEvent>[1] const davCalendar = { url: row.calendarUrl } as Parameters<typeof createCalendarEvent>[1]
+4
View File
@@ -106,6 +106,10 @@ const eventFieldsSchema = z.object({
description: z.string().max(2000).optional(), description: z.string().max(2000).optional(),
recurrence: z.enum(['none', 'daily', 'weekly', 'monthly', 'yearly']).optional(), recurrence: z.enum(['none', 'daily', 'weekly', 'monthly', 'yearly']).optional(),
calendarUrl: z.string().url().max(1024).optional(), calendarUrl: z.string().url().max(1024).optional(),
// D-06: recurrence bounding (RRULE UNTIL / COUNT)
// T-06-02: max(10) bounds 'YYYY-MM-DD'; int().min(1) prevents zero/negative counts
recurrenceUntil: z.string().max(10).optional(), // 'YYYY-MM-DD' → RRULE UNTIL
recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT
}) })
/** sync-status query params. */ /** sync-status query params. */