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:
@@ -79,11 +79,62 @@ const outboxPayloadSchema = z
|
||||
recurrence: z.enum(['none', 'daily', 'weekly', 'monthly', 'yearly']).optional(),
|
||||
calendarUrl: z.string().url().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()
|
||||
|
||||
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) ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -296,6 +347,41 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
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({
|
||||
uid: row.uid,
|
||||
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),
|
||||
location: fields.location as string | undefined,
|
||||
description: fields.description as string | undefined,
|
||||
rruleString: hasExplicitRecurrence ? rruleFromPayload : (preservedRrule ?? rruleFromPayload),
|
||||
rruleString: finalRruleString,
|
||||
})
|
||||
|
||||
response = await updateCalendarEvent(
|
||||
@@ -354,6 +440,39 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
typeof fields._preservedRrule === 'string' && fields._preservedRrule.length > 0
|
||||
? fields._preservedRrule
|
||||
: 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({
|
||||
uid: row.uid,
|
||||
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),
|
||||
location: fields.location 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)
|
||||
const davCalendar = { url: row.calendarUrl } as Parameters<typeof createCalendarEvent>[1]
|
||||
|
||||
@@ -106,6 +106,10 @@ const eventFieldsSchema = z.object({
|
||||
description: z.string().max(2000).optional(),
|
||||
recurrence: z.enum(['none', 'daily', 'weekly', 'monthly', 'yearly']).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. */
|
||||
|
||||
Reference in New Issue
Block a user