@forcecalendar/core 2.5.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -235,6 +235,35 @@ export class Calendar {
235
235
  return this.eventStore.getEvent(eventId);
236
236
  }
237
237
 
238
+ /**
239
+ * Resolve an event id or occurrence id to the id of the stored event it
240
+ * refers to: the id itself for a stored event, the master's id for an
241
+ * occurrence id taken from view data, `null` when nothing stored matches.
242
+ * See `EventStore.resolveEventId`.
243
+ *
244
+ * @example
245
+ * calendar.resolveEventId('standup_1750028400000'); // 'standup'
246
+ * calendar.resolveEventId('unknown'); // null
247
+ *
248
+ * @param {string} id - Event id or occurrence id
249
+ * @returns {string|null} Id of the stored event, or null
250
+ */
251
+ resolveEventId(id) {
252
+ return this.eventStore.resolveEventId(id);
253
+ }
254
+
255
+ /**
256
+ * Get the occurrence an occurrence id from view data stands for, as an
257
+ * {@link Event} like the ones the views hold, or `null` when the id is
258
+ * not an occurrence of a stored recurring series. See
259
+ * `EventStore.getOccurrence`.
260
+ * @param {string} occurrenceId - Occurrence id (`<masterId>_<startMs>`)
261
+ * @returns {Event|null} The occurrence, or null
262
+ */
263
+ getOccurrence(occurrenceId) {
264
+ return this.eventStore.getOccurrence(occurrenceId, this.config.timeZone);
265
+ }
266
+
238
267
  /**
239
268
  * Get all stored events (recurring masters, never their occurrences)
240
269
  * @returns {Event[]}
@@ -269,13 +298,14 @@ export class Calendar {
269
298
  if (options.reconcile) {
270
299
  return this.reconcileEvents(events, options);
271
300
  }
301
+ this._assertIterable(events, 'setEvents');
272
302
 
273
303
  const removed = this.getEvents();
274
304
  this.eventStore.loadEvents(events);
275
305
  const added = this.getEvents();
276
306
 
277
307
  /** @type {import('../types.js').EventsSetPayload} */
278
- const payload = { events: added, added, updated: [], removed, unchanged: [] };
308
+ const payload = { events: this.getEvents(), added, updated: [], removed, unchanged: [] };
279
309
  this._emit('eventsSet', payload);
280
310
  return payload;
281
311
  }
@@ -305,9 +335,10 @@ export class Calendar {
305
335
  * @param {Array<import('../events/Event.js').Event|import('../types.js').EventData>} events - Complete snapshot of events
306
336
  * @param {import('../types.js').ReconcileOptions} [options={}] - Reconcile options
307
337
  * @returns {import('../types.js').EventsSetPayload} Resulting events and the applied change set
308
- * @throws {Error} If an entry fails validation or two entries share an id
338
+ * @throws {Error} If `events` is not iterable, an entry fails validation or two entries share an id
309
339
  */
310
340
  reconcileEvents(events, options = {}) {
341
+ this._assertIterable(events, 'reconcileEvents');
311
342
  const { removeMissing, isEquivalent } = options;
312
343
  const prepared = [];
313
344
  for (const eventData of events) {
@@ -330,6 +361,18 @@ export class Calendar {
330
361
  return payload;
331
362
  }
332
363
 
364
+ /**
365
+ * Throw a clear error when a snapshot is not iterable
366
+ * @param {*} events - Candidate snapshot
367
+ * @param {string} method - Calling method, for the message
368
+ * @private
369
+ */
370
+ _assertIterable(events, method) {
371
+ if (!events || typeof events[Symbol.iterator] !== 'function') {
372
+ throw new Error(`${method}() expects an iterable of events`);
373
+ }
374
+ }
375
+
333
376
  /**
334
377
  * Get the event store's change counter
335
378
  *
@@ -402,7 +445,7 @@ export class Calendar {
402
445
  * remind(occurrence);
403
446
  * }
404
447
  *
405
- * @param {string} eventId - The event ID
448
+ * @param {string} eventId - Event id or occurrence id (resolved to its master)
406
449
  * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
407
450
  * @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>} Occurrences in chronological order
408
451
  * @throws {Error} If no event with the ID exists
@@ -419,7 +462,7 @@ export class Calendar {
419
462
  * @example
420
463
  * const upcoming = calendar.getNextOccurrence('standup', new Date());
421
464
  *
422
- * @param {string} eventId - The event ID
465
+ * @param {string} eventId - Event id or occurrence id (resolved to its master)
423
466
  * @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
424
467
  * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Further options
425
468
  * @returns {import('../types.js').ExpandedOccurrence|null} The next occurrence, or null
@@ -436,8 +479,8 @@ export class Calendar {
436
479
  * @example
437
480
  * const nextFive = calendar.takeOccurrences('standup', 5, { after: new Date() });
438
481
  *
439
- * @param {string} eventId - The event ID
440
- * @param {number} count - Maximum number of occurrences to return
482
+ * @param {string} eventId - Event id or occurrence id (resolved to its master)
483
+ * @param {number} count - Maximum number of occurrences to return (fractions are floored)
441
484
  * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
442
485
  * @returns {import('../types.js').ExpandedOccurrence[]} Up to `count` occurrences in chronological order
443
486
  * @throws {Error} If no event with the ID exists
@@ -704,11 +747,19 @@ export class Calendar {
704
747
  * @private
705
748
  */
706
749
  _getDayViewData(date) {
750
+ const timezone = this.config.timeZone;
707
751
  const events = this.getEventsForDate(date);
708
752
 
709
- // Separate all-day and timed events
753
+ // Separate all-day and timed events. Hour slots are placed on the
754
+ // calendar's wall clock, so timed events are compared in that timezone.
710
755
  const allDayEvents = events.filter(e => e.allDay);
711
- const timedEvents = events.filter(e => !e.allDay);
756
+ const timedEvents = events
757
+ .filter(e => !e.allDay)
758
+ .map(event => ({
759
+ event,
760
+ start: event.getStartInTimezone(timezone),
761
+ end: event.getEndInTimezone(timezone)
762
+ }));
712
763
 
713
764
  // Create hourly slots for timed events
714
765
  const hours = [];
@@ -721,11 +772,13 @@ export class Calendar {
721
772
  hours.push({
722
773
  hour,
723
774
  time: DateUtils.formatTime(hourDate, this.state.get('locale')),
724
- events: timedEvents.filter(event => {
725
- // Check if event occurs during this hour (not just starts)
726
- // Event occurs in this hour if it overlaps with the hour slot
727
- return event.start < hourEnd && event.end > hourDate;
728
- })
775
+ events: timedEvents
776
+ .filter(({ start, end }) => {
777
+ // Check if event occurs during this hour (not just starts)
778
+ // Event occurs in this hour if it overlaps with the hour slot
779
+ return start < hourEnd && end > hourDate;
780
+ })
781
+ .map(({ event }) => event)
729
782
  });
730
783
  }
731
784
 
@@ -787,14 +840,37 @@ export class Calendar {
787
840
 
788
841
  /**
789
842
  * Select an event
790
- * @param {string} eventId - Event ID to select
843
+ *
844
+ * Accepts a stored event's id or an occurrence id taken from view data
845
+ * (see {@link Event.occurrenceId}). Either way the stored event is what
846
+ * gets selected: `selectedEventId` in the state holds its id, so it can
847
+ * always be looked up with {@link Calendar#getEvent}. The `eventSelect`
848
+ * payload carries the stored `event`, its `eventId`, and for an
849
+ * occurrence id also `occurrenceId` and the `occurrence` itself (an
850
+ * {@link Event} as in view data, or null when the series has no
851
+ * occurrence at that instant). Nothing happens for an unknown id.
852
+ *
853
+ * @example
854
+ * calendar.on('eventSelect', ({ event, occurrence }) => open(occurrence || event));
855
+ * calendar.selectEvent(chip.dataset.eventId);
856
+ *
857
+ * @param {string} eventId - Event id or occurrence id to select
791
858
  */
792
859
  selectEvent(eventId) {
793
860
  const event = this.getEvent(eventId);
794
- if (event) {
795
- this.state.selectEvent(eventId);
796
- this._emit('eventSelect', { event });
861
+ if (!event) {
862
+ return;
797
863
  }
864
+ const isOccurrence = eventId !== event.id;
865
+ this.state.selectEvent(event.id);
866
+ /** @type {import('../types.js').EventSelectPayload} */
867
+ const payload = {
868
+ event,
869
+ eventId: event.id,
870
+ occurrenceId: isOccurrence ? eventId : null,
871
+ occurrence: isOccurrence ? this.getOccurrence(eventId) : null
872
+ };
873
+ this._emit('eventSelect', payload);
798
874
  }
799
875
 
800
876
  /**
@@ -42,6 +42,33 @@ function deepEqual(a, b) {
42
42
  return true;
43
43
  }
44
44
 
45
+ /**
46
+ * Timezone identifiers Intl has already accepted. Every Event construction
47
+ * (each clone, each occurrence of a recurring series) validates its
48
+ * timezone, and constructing an Intl.DateTimeFormat per event dominated
49
+ * the cost of expanding a month grid.
50
+ * @type {Set<string>}
51
+ */
52
+ const validatedTimezones = new Set();
53
+
54
+ /**
55
+ * Throw unless Intl accepts the timezone identifier
56
+ * @param {string} timezone - IANA timezone identifier
57
+ * @param {string} label - Field name for the error message
58
+ * @throws {Error} If the timezone is not valid
59
+ */
60
+ function assertValidTimezone(timezone, label) {
61
+ if (validatedTimezones.has(timezone)) {
62
+ return;
63
+ }
64
+ try {
65
+ new Intl.DateTimeFormat('en-US', { timeZone: timezone });
66
+ } catch (e) {
67
+ throw new Error(`Invalid ${label}: ${timezone}`, { cause: e });
68
+ }
69
+ validatedTimezones.add(timezone);
70
+ }
71
+
45
72
  export class Event {
46
73
  // Field size limits
47
74
  static FIELD_LIMITS = {
@@ -204,22 +231,12 @@ export class Event {
204
231
  });
205
232
  }
206
233
 
207
- // Validate timezone if provided
234
+ // Validate timezones if provided (memoised per identifier)
208
235
  if (data.timeZone) {
209
- try {
210
- new Intl.DateTimeFormat('en-US', { timeZone: data.timeZone });
211
- } catch (e) {
212
- throw new Error(`Invalid timezone: ${data.timeZone}`, { cause: e });
213
- }
236
+ assertValidTimezone(data.timeZone, 'timezone');
214
237
  }
215
-
216
- // Validate end timezone if provided
217
238
  if (data.endTimeZone) {
218
- try {
219
- new Intl.DateTimeFormat('en-US', { timeZone: data.endTimeZone });
220
- } catch (e) {
221
- throw new Error(`Invalid end timezone: ${data.endTimeZone}`, { cause: e });
222
- }
239
+ assertValidTimezone(data.endTimeZone, 'end timezone');
223
240
  }
224
241
  }
225
242
 
@@ -660,6 +677,8 @@ export class Event {
660
677
  * Scalars are compared with strict equality, dates by timestamp and
661
678
  * structured fields (recurrence rule, organizer, attendees, reminders,
662
679
  * categories, attachments, conference data, metadata) structurally.
680
+ * The `color` shorthand is not listed: normalization copies it into
681
+ * `backgroundColor` and `borderColor`, which are compared instead.
663
682
  * @type {ReadonlyArray<string>}
664
683
  */
665
684
  static EQUIVALENCE_FIELDS = Object.freeze([
@@ -672,7 +691,6 @@ export class Event {
672
691
  'endTimeZone',
673
692
  'description',
674
693
  'location',
675
- 'color',
676
694
  'backgroundColor',
677
695
  'borderColor',
678
696
  'textColor',
@@ -704,6 +722,14 @@ export class Event {
704
722
  * describe the same event. Two events with different ids are never
705
723
  * equivalent.
706
724
  *
725
+ * Two things to know when building snapshots for `reconcile()`:
726
+ * - `attendees`, `reminders`, `categories` and `attachments` are compared
727
+ * in order, so the same attendees listed in a different order count as
728
+ * a change.
729
+ * - Only the top-level dates are normalized. Values inside `metadata` are
730
+ * compared as given, so a `Date` and its ISO string are not equivalent
731
+ * there; keep metadata in one representation.
732
+ *
707
733
  * This is the default comparator used by `EventStore.reconcile()` to decide
708
734
  * whether an incoming snapshot entry replaces the stored event.
709
735
  *
@@ -747,10 +773,15 @@ export class Event {
747
773
  * @param {string} recurringEventId - Id of the recurring master event
748
774
  * @param {Date|number|string} occurrenceStart - Start of the occurrence
749
775
  * @returns {string} Occurrence id
776
+ * @throws {TypeError} If occurrenceStart is not a valid date
750
777
  */
751
778
  static occurrenceId(recurringEventId, occurrenceStart) {
752
779
  const start = occurrenceStart instanceof Date ? occurrenceStart : new Date(occurrenceStart);
753
- return `${recurringEventId}_${start.getTime()}`;
780
+ const startMs = start.getTime();
781
+ if (Number.isNaN(startMs)) {
782
+ throw new TypeError('Event.occurrenceId: occurrenceStart must be a valid Date or timestamp');
783
+ }
784
+ return `${recurringEventId}_${startMs}`;
754
785
  }
755
786
 
756
787
  /**
@@ -5,6 +5,12 @@ import { PerformanceOptimizer } from '../performance/PerformanceOptimizer.js';
5
5
  import { ConflictDetector } from '../conflicts/ConflictDetector.js';
6
6
  import { TimezoneManager } from '../timezone/TimezoneManager.js';
7
7
 
8
+ // Events are indexed and expanded on their own wall clock, which can differ
9
+ // from the query timezone's by up to 26 hours (UTC-12 to UTC+14). Day
10
+ // queries therefore look this many days beyond each edge before filtering
11
+ // precisely in the query timezone.
12
+ const TIMEZONE_PAD_DAYS = 2;
13
+
8
14
  /**
9
15
  * EventStore - Manages calendar events with efficient querying
10
16
  * Uses Map for O(1) lookups and spatial indexing concepts for date queries
@@ -148,6 +154,7 @@ export class EventStore {
148
154
  // Clear query and date range caches since results may have changed
149
155
  this.optimizer.queryCache.clear();
150
156
  this.optimizer.dateRangeCache.clear();
157
+ this._invalidateOccurrenceCache(replacement.id);
151
158
 
152
159
  // Re-index
153
160
  this._indexEvent(replacement);
@@ -192,11 +199,31 @@ export class EventStore {
192
199
  this.optimizer.eventCache.delete(event.id);
193
200
  this.optimizer.queryCache.clear();
194
201
  this.optimizer.dateRangeCache.clear();
202
+ this._invalidateOccurrenceCache(event.id);
195
203
 
196
204
  // Remove from indices
197
205
  this._unindexEvent(event);
198
206
  }
199
207
 
208
+ /**
209
+ * Drop the recurrence engine's cached expansions of one series, or of
210
+ * every series when no id is given. The engine is pluggable, so both
211
+ * hooks are optional.
212
+ * @param {string} [eventId] - Series to invalidate; omit to clear everything
213
+ * @private
214
+ */
215
+ _invalidateOccurrenceCache(eventId = null) {
216
+ const engine = this.recurrenceEngine;
217
+ if (!engine) {
218
+ return;
219
+ }
220
+ if (eventId !== null && typeof engine.clearEventCache === 'function') {
221
+ engine.clearEventCache(eventId);
222
+ } else if (engine.occurrenceCache instanceof Map) {
223
+ engine.occurrenceCache.clear();
224
+ }
225
+ }
226
+
200
227
  /**
201
228
  * Get an event by ID
202
229
  *
@@ -242,6 +269,60 @@ export class EventStore {
242
269
  return master && master.recurring ? master : null;
243
270
  }
244
271
 
272
+ /**
273
+ * Resolve an id taken from anywhere (store, view data, a drag or click
274
+ * handler) to the id of the stored event it refers to.
275
+ *
276
+ * Returns the id itself for a stored event, the master's id for an
277
+ * occurrence id (see {@link Event.occurrenceId}) whose master is a stored
278
+ * recurring event, and `null` when nothing stored matches. This is the
279
+ * same resolution {@link EventStore#getEvent}, updateEvent and removeEvent
280
+ * apply, exposed for consumers that only need the id.
281
+ *
282
+ * @example
283
+ * const masterId = store.resolveEventId(chip.dataset.eventId); // 'standup' for 'standup_1750028400000'
284
+ *
285
+ * @param {string} id - Event id or occurrence id
286
+ * @returns {string|null} Id of the stored event, or null
287
+ */
288
+ resolveEventId(id) {
289
+ const event = this.getEvent(id);
290
+ return event ? event.id : null;
291
+ }
292
+
293
+ /**
294
+ * Get the occurrence an occurrence id stands for, as an {@link Event}
295
+ * exactly like the ones {@link EventStore#expandRecurringEvent} returns.
296
+ *
297
+ * Returns `null` when the id is not an occurrence id, its master is not a
298
+ * stored recurring event, or the series has no occurrence starting at the
299
+ * encoded instant. A master id is never an occurrence, so it yields null
300
+ * too; use {@link EventStore#getEvent} for the master.
301
+ *
302
+ * @example
303
+ * const occurrence = store.getOccurrence('standup_1750028400000');
304
+ *
305
+ * @param {string} occurrenceId - Occurrence id (`<masterId>_<startMs>`)
306
+ * @param {string} [timezone] - Timezone for the expansion (defaults to the store timezone)
307
+ * @returns {Event|null} The occurrence, or null
308
+ */
309
+ getOccurrence(occurrenceId, timezone = null) {
310
+ const parsed = Event.parseOccurrenceId(occurrenceId);
311
+ const master = parsed ? this._resolveOccurrenceMaster(occurrenceId) : null;
312
+ if (!master || Number.isNaN(parsed.occurrenceStart.getTime())) {
313
+ return null;
314
+ }
315
+ // A DST adjustment can move an occurrence away from the stepped
316
+ // instant, so expand a day either side and match on the id
317
+ const occurrences = this.expandRecurringEvent(
318
+ master,
319
+ DateUtils.addDays(parsed.occurrenceStart, -1),
320
+ DateUtils.addDays(parsed.occurrenceStart, 1),
321
+ timezone
322
+ );
323
+ return occurrences.find(occurrence => occurrence.id === occurrenceId) || null;
324
+ }
325
+
245
326
  /**
246
327
  * Get all events
247
328
  * @returns {Event[]} Array of all events
@@ -394,10 +475,14 @@ export class EventStore {
394
475
  }
395
476
  }
396
477
 
478
+ // Series are expanded on their own wall clock, so cover the offset
479
+ // spread and let _selectEventsForDay decide in the query timezone
480
+ const expandStart = DateUtils.addDays(dayStart, -TIMEZONE_PAD_DAYS);
481
+ const expandEnd = DateUtils.addDays(dayEnd, TIMEZONE_PAD_DAYS);
397
482
  for (const id of this.indices.recurring) {
398
483
  const event = this.events.get(id);
399
484
  if (event) {
400
- candidates.push(...this.expandRecurringEvent(event, dayStart, dayEnd, timezone));
485
+ candidates.push(...this.expandRecurringEvent(event, expandStart, expandEnd, timezone));
401
486
  }
402
487
  }
403
488
 
@@ -433,11 +518,11 @@ export class EventStore {
433
518
  byDate.set(DateUtils.getLocalDateString(day), []);
434
519
  }
435
520
 
436
- // Query one day beyond each edge so events that fall on an edge day in
437
- // the requested timezone are not lost to the UTC-based range filter.
521
+ // Query beyond each edge so events that fall on an edge day in the
522
+ // requested timezone are not lost to the range filter on event wall clocks.
438
523
  const events = this.getEventsInRange(
439
- DateUtils.addDays(rangeStart, -1),
440
- DateUtils.addDays(rangeEnd, 1),
524
+ DateUtils.addDays(rangeStart, -TIMEZONE_PAD_DAYS),
525
+ DateUtils.addDays(rangeEnd, TIMEZONE_PAD_DAYS),
441
526
  true,
442
527
  timezone
443
528
  );
@@ -472,7 +557,7 @@ export class EventStore {
472
557
 
473
558
  // Check byDate index for nearby dates (handles most events)
474
559
  const checkDate = new Date(date);
475
- for (let offset = -1; offset <= 1; offset++) {
560
+ for (let offset = -TIMEZONE_PAD_DAYS; offset <= TIMEZONE_PAD_DAYS; offset++) {
476
561
  const tempDate = new Date(checkDate);
477
562
  tempDate.setDate(tempDate.getDate() + offset);
478
563
  const tempDateStr = DateUtils.getLocalDateString(tempDate);
@@ -869,7 +954,7 @@ export class EventStore {
869
954
  * remind(occurrence);
870
955
  * }
871
956
  *
872
- * @param {string} eventId - The event ID
957
+ * @param {string} eventId - Event id or occurrence id (resolved to its master)
873
958
  * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
874
959
  * @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>} Occurrences in chronological order
875
960
  * @throws {Error} If no event with the ID exists
@@ -887,7 +972,7 @@ export class EventStore {
887
972
  * @example
888
973
  * const upcoming = store.getNextOccurrence('standup', new Date());
889
974
  *
890
- * @param {string} eventId - The event ID
975
+ * @param {string} eventId - Event id or occurrence id (resolved to its master)
891
976
  * @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
892
977
  * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Further options
893
978
  * @returns {import('../types.js').ExpandedOccurrence|null} The next occurrence, or null
@@ -906,8 +991,8 @@ export class EventStore {
906
991
  * @example
907
992
  * const nextFive = store.takeOccurrences('standup', 5, { after: new Date() });
908
993
  *
909
- * @param {string} eventId - The event ID
910
- * @param {number} count - Maximum number of occurrences to return
994
+ * @param {string} eventId - Event id or occurrence id (resolved to its master)
995
+ * @param {number} count - Maximum number of occurrences to return (fractions are floored)
911
996
  * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
912
997
  * @returns {import('../types.js').ExpandedOccurrence[]} Up to `count` occurrences in chronological order
913
998
  * @throws {Error} If no event with the ID exists
@@ -927,7 +1012,8 @@ export class EventStore {
927
1012
  * @private
928
1013
  */
929
1014
  _occurrenceQuery(eventId, options) {
930
- const event = this.events.get(eventId);
1015
+ // Occurrence ids resolve to their master, as in getEvent
1016
+ const event = this.getEvent(eventId);
931
1017
  if (!event) {
932
1018
  throw new Error(`Event with id ${eventId} not found`);
933
1019
  }
@@ -950,6 +1036,7 @@ export class EventStore {
950
1036
  this.indices.byCategory.clear();
951
1037
  this.indices.byStatus.clear();
952
1038
  this.eventIndexRefs.clear();
1039
+ this._invalidateOccurrenceCache();
953
1040
 
954
1041
  this._notifyChange({
955
1042
  type: 'clear',
@@ -982,13 +1069,23 @@ export class EventStore {
982
1069
  * - adds events whose id is not in the store (`add` change),
983
1070
  * - removes stored events missing from the snapshot (`remove` change),
984
1071
  * unless `removeMissing` is `false`,
1072
+ * - treats occurrences of a recurring series (entries with
1073
+ * `isOccurrence: true`, the plain object of such an occurrence, or an id
1074
+ * that {@link EventStore#getEvent} resolves to a stored recurring master)
1075
+ * as a reference to their master: the master is kept as unchanged and is
1076
+ * never replaced by an occurrence. An occurrence whose master is neither
1077
+ * stored nor in the snapshot is an error,
985
1078
  * - emits a single `batch` notification listing those changes, or nothing at
986
1079
  * all when the snapshot matches the store. When called while a batch is
987
1080
  * already open the changes are queued on that batch instead.
988
1081
  *
989
- * Input is validated up front: invalid event data or duplicate ids throw
990
- * before the store is modified. Any error raised while applying the diff
991
- * rolls the store back to its previous state.
1082
+ * Input is validated up front: invalid event data, duplicate ids and
1083
+ * occurrences without a master throw before the store is modified. When
1084
+ * reconcile opens the batch itself, any error raised while applying the
1085
+ * diff rolls the store back to its previous state; inside a batch opened
1086
+ * by the caller the changes applied so far stay queued on that batch, and
1087
+ * it is the caller's rollbackBatch() that undoes them (a custom
1088
+ * `isEquivalent` that throws is the usual way to get there).
992
1089
  *
993
1090
  * @example
994
1091
  * // periodic server snapshot
@@ -1014,7 +1111,13 @@ export class EventStore {
1014
1111
  // Normalize and validate everything before touching the store
1015
1112
  /** @type {Map<string, Event>} */
1016
1113
  const incoming = new Map();
1114
+ const occurrenceRefs = [];
1017
1115
  for (const eventData of events) {
1116
+ const masterId = this._occurrenceMasterId(eventData);
1117
+ if (masterId !== null) {
1118
+ occurrenceRefs.push({ id: eventData.id, masterId });
1119
+ continue;
1120
+ }
1018
1121
  const event = eventData instanceof Event ? eventData : new Event(eventData);
1019
1122
  if (incoming.has(event.id)) {
1020
1123
  throw new Error(`Duplicate event id in reconcile input: ${event.id}`);
@@ -1022,6 +1125,22 @@ export class EventStore {
1022
1125
  incoming.set(event.id, event);
1023
1126
  }
1024
1127
 
1128
+ // Stored masters that occurrence entries stand for: kept, not compared
1129
+ /** @type {Set<string>} */
1130
+ const retained = new Set();
1131
+ for (const { id, masterId } of occurrenceRefs) {
1132
+ if (incoming.has(masterId)) {
1133
+ continue; // the master itself is in the snapshot
1134
+ }
1135
+ const stored = this.events.get(masterId);
1136
+ if (!stored || !stored.recurring) {
1137
+ throw new Error(
1138
+ `Occurrence ${id} in reconcile input refers to recurring event ${masterId}, which is neither stored nor in the snapshot`
1139
+ );
1140
+ }
1141
+ retained.add(masterId);
1142
+ }
1143
+
1025
1144
  /** @type {import('../types.js').ReconcileResult} */
1026
1145
  const result = { added: [], updated: [], removed: [], unchanged: [] };
1027
1146
 
@@ -1034,7 +1153,7 @@ export class EventStore {
1034
1153
  try {
1035
1154
  if (removeMissing) {
1036
1155
  for (const existing of Array.from(this.events.values())) {
1037
- if (!incoming.has(existing.id)) {
1156
+ if (!incoming.has(existing.id) && !retained.has(existing.id)) {
1038
1157
  this._detachEvent(existing);
1039
1158
  this._queueChange({ type: 'remove', event: existing, version: ++this.version });
1040
1159
  result.removed.push(existing);
@@ -1042,6 +1161,10 @@ export class EventStore {
1042
1161
  }
1043
1162
  }
1044
1163
 
1164
+ for (const masterId of retained) {
1165
+ result.unchanged.push(this.events.get(masterId));
1166
+ }
1167
+
1045
1168
  for (const event of incoming.values()) {
1046
1169
  const existing = this.events.get(event.id);
1047
1170
  if (!existing) {
@@ -1084,6 +1207,44 @@ export class EventStore {
1084
1207
  });
1085
1208
  }
1086
1209
 
1210
+ /**
1211
+ * Id of the recurring master a reconcile entry is an occurrence of, or
1212
+ * null when the entry is an event in its own right. Recognises Event
1213
+ * occurrences (isOccurrence), their toObject() form (occurrence markers
1214
+ * in metadata) and ids that resolve to a stored recurring master.
1215
+ * @param {Event|import('../types.js').EventData} eventData - Reconcile entry
1216
+ * @returns {string|null} Master id or null
1217
+ * @private
1218
+ */
1219
+ _occurrenceMasterId(eventData) {
1220
+ if (!eventData || typeof eventData !== 'object') {
1221
+ return null; // let the Event constructor report it
1222
+ }
1223
+ const parsed = Event.parseOccurrenceId(eventData.id);
1224
+ if (eventData.isOccurrence === true) {
1225
+ if (typeof eventData.recurringEventId === 'string' && eventData.recurringEventId) {
1226
+ return eventData.recurringEventId;
1227
+ }
1228
+ if (parsed) {
1229
+ return parsed.recurringEventId;
1230
+ }
1231
+ throw new Error(
1232
+ `Occurrence ${eventData.id} in reconcile input has no recurringEventId and no occurrence id`
1233
+ );
1234
+ }
1235
+ const metadata = eventData.metadata;
1236
+ if (
1237
+ metadata &&
1238
+ typeof metadata === 'object' &&
1239
+ typeof metadata.recurringEventId === 'string' &&
1240
+ metadata.occurrenceId === eventData.id
1241
+ ) {
1242
+ return metadata.recurringEventId;
1243
+ }
1244
+ const master = parsed ? this._resolveOccurrenceMaster(eventData.id) : null;
1245
+ return master ? master.id : null;
1246
+ }
1247
+
1087
1248
  /**
1088
1249
  * Subscribe to store changes
1089
1250
  * @param {Function} callback - Callback function
@@ -1324,10 +1485,6 @@ export class EventStore {
1324
1485
  }
1325
1486
  }
1326
1487
 
1327
- /**
1328
- * Notify listeners of changes
1329
- * @private
1330
- */
1331
1488
  /**
1332
1489
  * Deliver a change now, or queue it when a batch is open
1333
1490
  * @param {import('../types.js').EventStoreChange} change - Change to deliver
@@ -1341,6 +1498,12 @@ export class EventStore {
1341
1498
  }
1342
1499
  }
1343
1500
 
1501
+ /**
1502
+ * Deliver a change to every subscriber now, regardless of batch mode.
1503
+ * A listener that throws is reported and does not stop the others.
1504
+ * @param {import('../types.js').EventStoreChange} change - Change to deliver
1505
+ * @private
1506
+ */
1344
1507
  _notifyChange(change) {
1345
1508
  for (const listener of this.listeners) {
1346
1509
  try {
@@ -1609,12 +1772,13 @@ export class EventStore {
1609
1772
  }
1610
1773
 
1611
1774
  /**
1612
- * Clear all caches
1775
+ * Clear all caches, including the recurrence engine's cached expansions
1613
1776
  */
1614
1777
  clearCaches() {
1615
1778
  this.optimizer.eventCache.clear();
1616
1779
  this.optimizer.queryCache.clear();
1617
1780
  this.optimizer.dateRangeCache.clear();
1781
+ this._invalidateOccurrenceCache();
1618
1782
  }
1619
1783
 
1620
1784
  /**