fix: mode
Some checks are pending
Deploy / deploy (push) Waiting to run

This commit is contained in:
Benno Tielen 2026-06-24 08:23:43 +02:00
parent a0821f1ae7
commit 1457fb4920

View file

@ -10,24 +10,31 @@ import {
* Materializes each Event's recurrence rule into `eventOccurrence` rows for * Materializes each Event's recurrence rule into `eventOccurrence` rows for
* a rolling window. * a rolling window.
* *
* Per event we wipe every future occurrence and regenerate from the current * Two modes:
* rule. Occurrences are derivative pointers to the parent event, so there * - `replace` (default): wipe every future occurrence and regenerate from
* is no editor-edited state to preserve rule edits and recurringonce * the current rule. Used by the Events `afterChange` hook, where the rule
* transitions stay trivially correct. * or date may have changed wiping keeps rule edits and recurringonce
* transitions trivially correct.
* - `append`: never delete; only create occurrences whose slot doesn't yet
* exist. Used by the periodic backfill job, where the rule hasn't changed
* and we only want to extend the window forward without churning (and
* re-id'ing) occurrences that are already correct.
* *
* Weekday and time-of-day come directly from `event.date`. Calendar-date * Weekday and time-of-day come directly from `event.date`. Calendar-date
* generation lives in scheduleOccurrences.ts (DST-safe by construction: * generation lives in scheduleOccurrences.ts (DST-safe by construction:
* dates are built at local midnight and then stitched with the event's * dates are built at local midnight and then stitched with the event's
* HH:mm). * HH:mm).
* *
* Exposed both as a Payload Jobs Queue task (weekly cron backfill) and as a * Exposed both as a Payload Jobs Queue task (cron backfill) and as a direct
* direct function called from the Events `afterChange` hook so edits are * function called from the Events `afterChange` hook so edits are reflected
* reflected immediately without waiting for a queue worker. * immediately without waiting for a queue worker.
*/ */
const DEFAULT_WEEKS_AHEAD = 8 const DEFAULT_WEEKS_AHEAD = 8
const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000 const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000
type RegenerateMode = 'replace' | 'append'
type GenerateEventOccurrencesInput = { type GenerateEventOccurrencesInput = {
weeksAhead?: number weeksAhead?: number
eventId?: string eventId?: string
@ -60,19 +67,22 @@ export const regenerateOccurrencesForEvent = async ({
req, req,
weeksAhead = DEFAULT_WEEKS_AHEAD, weeksAhead = DEFAULT_WEEKS_AHEAD,
now = new Date(), now = new Date(),
mode = 'replace',
}: { }: {
event: EventLike event: EventLike
payload: Payload payload: Payload
req?: PayloadRequest req?: PayloadRequest
weeksAhead?: number weeksAhead?: number
now?: Date now?: Date
mode?: RegenerateMode
}): Promise<GenerateEventOccurrencesOutput> => { }): Promise<GenerateEventOccurrencesOutput> => {
const horizon = new Date(now.getTime() + weeksAhead * MS_PER_WEEK) const horizon = new Date(now.getTime() + weeksAhead * MS_PER_WEEK)
// Snapshot cancelled dates so manual cancellations survive the wipe/regen. // Existing future occurrences. Used to (replace) snapshot cancellations so
// Matching by ISO date works because regen derives timestamps from the same // they survive the wipe, and (append) dedup so we don't recreate slots that
// event.date stepped by whole calendar days — shi)fts in event.date // already exist. Matching by ISO date works because regen derives
// intentionally drop stale cancellations. // timestamps from the same event.date stepped by whole calendar days —
// shifts in event.date intentionally drop stale cancellations.
const existing = await payload.find({ const existing = await payload.find({
collection: 'eventOccurrence', collection: 'eventOccurrence',
where: { where: {
@ -91,21 +101,32 @@ export const regenerateOccurrencesForEvent = async ({
.filter((occ) => occ.cancelled === true && typeof occ.date === 'string') .filter((occ) => occ.cancelled === true && typeof occ.date === 'string')
.map((occ) => new Date(occ.date as string).toISOString()), .map((occ) => new Date(occ.date as string).toISOString()),
) )
const existingSlots = new Set(
existing.docs
.filter((occ) => typeof occ.date === 'string')
.map((occ) => new Date(occ.date as string).toISOString()),
)
const wipe = await payload.delete({ let deleted = 0
collection: 'eventOccurrence',
where: {
and: [
{ event: { equals: event.id } },
{ date: { greater_than_equal: now.toISOString() } },
],
},
req,
})
let deleted = wipe.docs.length
let created = 0 let created = 0
let skipped = 0 let skipped = 0
if (mode === 'replace') {
// Wipe future occurrences and rebuild from the current rule. Used by the
// Event hook, where the rule/date may have changed.
const wipe = await payload.delete({
collection: 'eventOccurrence',
where: {
and: [
{ event: { equals: event.id } },
{ date: { greater_than_equal: now.toISOString() } },
],
},
req,
})
deleted = wipe.docs.length
}
if (!event.date) { if (!event.date) {
return { created, deleted, skipped: skipped + 1 } return { created, deleted, skipped: skipped + 1 }
} }
@ -128,16 +149,21 @@ export const regenerateOccurrencesForEvent = async ({
const recurrenceType = event.recurrenceType ?? 'none' const recurrenceType = event.recurrenceType ?? 'none'
if (recurrenceType === 'none') { if (recurrenceType === 'none') {
const iso = eventDate.toISOString()
if (eventDate.getTime() < now.getTime()) { if (eventDate.getTime() < now.getTime()) {
return { created, deleted, skipped: skipped + 1 } return { created, deleted, skipped: skipped + 1 }
} }
// In append mode the slot may already exist (not wiped) — leave it alone.
if (mode === 'append' && existingSlots.has(iso)) {
return { created, deleted, skipped: skipped + 1 }
}
await payload.create({ await payload.create({
collection: 'eventOccurrence', collection: 'eventOccurrence',
data: { data: {
event: event.id, event: event.id,
date: eventDate.toISOString(), date: iso,
endDateTime: endFor(eventDate), endDateTime: endFor(eventDate),
cancelled: cancelledDates.has(eventDate.toISOString()), cancelled: cancelledDates.has(iso),
generated: true, generated: true,
}, },
req, req,
@ -152,6 +178,12 @@ export const regenerateOccurrencesForEvent = async ({
const dates = expandEventOccurrences(event, { now, horizon: effectiveEnd }) const dates = expandEventOccurrences(event, { now, horizon: effectiveEnd })
for (const date of dates) { for (const date of dates) {
const iso = date.toISOString() const iso = date.toISOString()
// In append mode the future occurrences were never wiped, so leave any
// slot that already has an occurrence alone.
if (mode === 'append' && existingSlots.has(iso)) {
skipped += 1
continue
}
await payload.create({ await payload.create({
collection: 'eventOccurrence', collection: 'eventOccurrence',
data: { data: {
@ -163,6 +195,7 @@ export const regenerateOccurrencesForEvent = async ({
}, },
req, req,
}) })
existingSlots.add(iso)
created += 1 created += 1
} }
@ -214,6 +247,10 @@ export const generateEventOccurrencesTask: TaskConfig<{
payload, payload,
weeksAhead, weeksAhead,
now, now,
// Periodic backfill: only extend the window forward. The rule hasn't
// changed here, so don't wipe/recreate occurrences that are already
// correct (that only happens via the Event hook on an actual edit).
mode: 'append',
}) })
created += result.created created += result.created
deleted += result.deleted deleted += result.deleted