@forcecalendar/core 2.3.0 → 2.5.0

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.
@@ -86,19 +86,11 @@ export class EventStore {
86
86
  this._indexEvent(event);
87
87
 
88
88
  // Notify listeners (batch if in batch mode)
89
- if (this.isBatchMode) {
90
- this.batchNotifications.push({
91
- type: 'add',
92
- event,
93
- version: ++this.version
94
- });
95
- } else {
96
- this._notifyChange({
97
- type: 'add',
98
- event,
99
- version: ++this.version
100
- });
101
- }
89
+ this._queueChange({
90
+ type: 'add',
91
+ event,
92
+ version: ++this.version
93
+ });
102
94
 
103
95
  return event;
104
96
  });
@@ -106,35 +98,24 @@ export class EventStore {
106
98
 
107
99
  /**
108
100
  * Update an existing event
109
- * @param {string} eventId - The event ID
101
+ *
102
+ * An occurrence id (see {@link Event.occurrenceId}) updates the recurring
103
+ * master the occurrence belongs to, i.e. the whole series.
104
+ * @param {string} eventId - Event id or occurrence id
110
105
  * @param {Partial<import('../types.js').EventData>} updates - Properties to update
111
- * @returns {Event} The updated event
106
+ * @returns {Event} The updated event (the master for an occurrence id)
112
107
  * @throws {Error} If event not found
113
108
  */
114
109
  updateEvent(eventId, updates) {
115
- const existingEvent = this.events.get(eventId);
110
+ const existingEvent = this.events.get(eventId) || this._resolveOccurrenceMaster(eventId);
116
111
  if (!existingEvent) {
117
112
  throw new Error(`Event with id ${eventId} not found`);
118
113
  }
119
114
 
120
- // Remove old indices
121
- this._unindexEvent(existingEvent);
122
-
123
115
  // Create updated event
124
116
  const updatedEvent = existingEvent.clone(updates);
125
117
 
126
- // Store updated event
127
- this.events.set(eventId, updatedEvent);
128
-
129
- // Update cache with new event data
130
- this.optimizer.cache(eventId, updatedEvent, 'event');
131
-
132
- // Clear query and date range caches since results may have changed
133
- this.optimizer.queryCache.clear();
134
- this.optimizer.dateRangeCache.clear();
135
-
136
- // Re-index
137
- this._indexEvent(updatedEvent);
118
+ this._replaceEvent(existingEvent, updatedEvent);
138
119
 
139
120
  // Notify listeners
140
121
  this._notifyChange({
@@ -147,27 +128,46 @@ export class EventStore {
147
128
  return updatedEvent;
148
129
  }
149
130
 
131
+ /**
132
+ * Swap a stored event for a new instance with the same id, keeping
133
+ * indices and caches in sync. Does not notify listeners.
134
+ * @param {Event} existingEvent - Event currently in the store
135
+ * @param {Event} replacement - Event instance that takes its place
136
+ * @private
137
+ */
138
+ _replaceEvent(existingEvent, replacement) {
139
+ // Remove old indices
140
+ this._unindexEvent(existingEvent);
141
+
142
+ // Store replacement
143
+ this.events.set(replacement.id, replacement);
144
+
145
+ // Update cache with new event data
146
+ this.optimizer.cache(replacement.id, replacement, 'event');
147
+
148
+ // Clear query and date range caches since results may have changed
149
+ this.optimizer.queryCache.clear();
150
+ this.optimizer.dateRangeCache.clear();
151
+
152
+ // Re-index
153
+ this._indexEvent(replacement);
154
+ }
155
+
150
156
  /**
151
157
  * Remove an event from the store
152
- * @param {string} eventId - The event ID to remove
158
+ *
159
+ * An occurrence id (see {@link Event.occurrenceId}) removes the recurring
160
+ * master the occurrence belongs to, i.e. the whole series.
161
+ * @param {string} eventId - Event id or occurrence id
153
162
  * @returns {boolean} True if removed, false if not found
154
163
  */
155
164
  removeEvent(eventId) {
156
- const event = this.events.get(eventId);
165
+ const event = this.events.get(eventId) || this._resolveOccurrenceMaster(eventId);
157
166
  if (!event) {
158
167
  return false;
159
168
  }
160
169
 
161
- // Remove from primary storage
162
- this.events.delete(eventId);
163
-
164
- // Invalidate caches
165
- this.optimizer.eventCache.delete(eventId);
166
- this.optimizer.queryCache.clear();
167
- this.optimizer.dateRangeCache.clear();
168
-
169
- // Remove from indices
170
- this._unindexEvent(event);
170
+ this._detachEvent(event);
171
171
 
172
172
  // Notify listeners
173
173
  this._notifyChange({
@@ -179,10 +179,33 @@ export class EventStore {
179
179
  return true;
180
180
  }
181
181
 
182
+ /**
183
+ * Remove an event from storage, caches and indices. Does not notify listeners.
184
+ * @param {Event} event - Event currently in the store
185
+ * @private
186
+ */
187
+ _detachEvent(event) {
188
+ // Remove from primary storage
189
+ this.events.delete(event.id);
190
+
191
+ // Invalidate caches
192
+ this.optimizer.eventCache.delete(event.id);
193
+ this.optimizer.queryCache.clear();
194
+ this.optimizer.dateRangeCache.clear();
195
+
196
+ // Remove from indices
197
+ this._unindexEvent(event);
198
+ }
199
+
182
200
  /**
183
201
  * Get an event by ID
184
- * @param {string} eventId - The event ID
185
- * @returns {Event|null} The event or null if not found
202
+ *
203
+ * Occurrence ids produced by {@link EventStore#expandRecurringEvent}
204
+ * (`<masterId>_<startMs>`, see {@link Event.occurrenceId}) resolve to the
205
+ * stored recurring master they were derived from, so an id taken from view
206
+ * data can always be looked up. Occurrences themselves are never stored.
207
+ * @param {string} eventId - Event id or occurrence id
208
+ * @returns {Event|null} The stored event (the master for an occurrence id) or null
186
209
  */
187
210
  getEvent(eventId) {
188
211
  // Check cache first
@@ -197,9 +220,26 @@ export class EventStore {
197
220
  // Cache if found
198
221
  if (event) {
199
222
  this.optimizer.cache(eventId, event, 'event');
223
+ return event;
200
224
  }
201
225
 
202
- return event;
226
+ return this._resolveOccurrenceMaster(eventId);
227
+ }
228
+
229
+ /**
230
+ * Resolve an occurrence id to the stored recurring master it belongs to.
231
+ * Not cached: the master's own cache entry is the one kept in sync.
232
+ * @param {string} eventId - Candidate occurrence id
233
+ * @returns {Event|null} The master event or null
234
+ * @private
235
+ */
236
+ _resolveOccurrenceMaster(eventId) {
237
+ const parsed = Event.parseOccurrenceId(eventId);
238
+ if (!parsed) {
239
+ return null;
240
+ }
241
+ const master = this.events.get(parsed.recurringEventId);
242
+ return master && master.recurring ? master : null;
203
243
  }
204
244
 
205
245
  /**
@@ -331,14 +371,103 @@ export class EventStore {
331
371
 
332
372
  /**
333
373
  * Get events for a specific date
374
+ *
375
+ * Recurring series are expanded for the day, so the result holds their
376
+ * occurrences (see {@link EventStore#expandRecurringEvent}) rather than the
377
+ * master events. When building a grid of days use
378
+ * {@link EventStore#getEventsByDate}, which expands once for the whole range.
334
379
  * @param {Date} date - The date to query
335
380
  * @param {string} [timezone] - Timezone for the query (defaults to store timezone)
336
381
  * @returns {Event[]} Events occurring on the date, sorted by start time
337
382
  */
338
383
  getEventsForDate(date, timezone = null) {
339
384
  timezone = timezone || this.defaultTimezone;
385
+ const dayStart = DateUtils.startOfDay(date);
386
+ const dayEnd = DateUtils.endOfDay(date);
387
+
388
+ const candidates = [];
389
+ for (const id of this._collectDateCandidateIds(date)) {
390
+ const event = this.events.get(id);
391
+ // Recurring masters are represented by their occurrences below
392
+ if (event && !event.recurring) {
393
+ candidates.push(event);
394
+ }
395
+ }
396
+
397
+ for (const id of this.indices.recurring) {
398
+ const event = this.events.get(id);
399
+ if (event) {
400
+ candidates.push(...this.expandRecurringEvent(event, dayStart, dayEnd, timezone));
401
+ }
402
+ }
403
+
404
+ return this._selectEventsForDay(candidates, dayStart, dayEnd, timezone);
405
+ }
406
+
407
+ /**
408
+ * Get the events for every day in a range, keyed by local date (YYYY-MM-DD)
409
+ *
410
+ * Recurring series are expanded once for the whole range rather than once
411
+ * per day, which is what a month or week grid needs. Every day in the range
412
+ * has an entry (an empty array when nothing occurs) and multi-day events
413
+ * appear under each day they span. Each array is sorted like
414
+ * {@link EventStore#getEventsForDate}.
415
+ *
416
+ * @example
417
+ * const byDate = store.getEventsByDate(gridStart, gridEnd);
418
+ * const events = byDate.get(DateUtils.getLocalDateString(cellDate)) || [];
419
+ *
420
+ * @param {Date} start - First day of the range
421
+ * @param {Date} end - Last day of the range
422
+ * @param {string} [timezone] - Timezone deciding which day an event falls on (defaults to store timezone)
423
+ * @returns {Map<string, Event[]>} Local date string -> events on that day
424
+ */
425
+ getEventsByDate(start, end, timezone = null) {
426
+ timezone = timezone || this.defaultTimezone;
427
+ const rangeStart = DateUtils.startOfDay(start);
428
+ const rangeEnd = DateUtils.endOfDay(end);
429
+
430
+ /** @type {Map<string, Event[]>} */
431
+ const byDate = new Map();
432
+ for (const day of DateUtils.getDateRange(rangeStart, rangeEnd)) {
433
+ byDate.set(DateUtils.getLocalDateString(day), []);
434
+ }
340
435
 
341
- // Collect candidate event IDs from indices
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.
438
+ const events = this.getEventsInRange(
439
+ DateUtils.addDays(rangeStart, -1),
440
+ DateUtils.addDays(rangeEnd, 1),
441
+ true,
442
+ timezone
443
+ );
444
+
445
+ for (const event of events) {
446
+ const eventStart = event.getStartInTimezone(timezone);
447
+ const eventEnd = event.getEndInTimezone(timezone);
448
+ const lastDay = eventEnd < rangeEnd ? eventEnd : rangeEnd;
449
+ let day = DateUtils.startOfDay(eventStart > rangeStart ? eventStart : rangeStart);
450
+ while (day <= lastDay) {
451
+ byDate.get(DateUtils.getLocalDateString(day))?.push(event);
452
+ day = DateUtils.addDays(day, 1);
453
+ }
454
+ }
455
+
456
+ const compare = this._compareByStart(timezone);
457
+ for (const dayEvents of byDate.values()) {
458
+ dayEvents.sort(compare);
459
+ }
460
+
461
+ return byDate;
462
+ }
463
+
464
+ /**
465
+ * Collect the ids of stored events that may occur on a date.
466
+ * @param {Date} date - The date to query
467
+ * @returns {Set<string>} Candidate event ids
468
+ * @private
469
+ */
470
+ _collectDateCandidateIds(date) {
342
471
  const candidateIds = new Set();
343
472
 
344
473
  // Check byDate index for nearby dates (handles most events)
@@ -362,35 +491,41 @@ export class EventStore {
362
491
  monthEventIds.forEach(id => candidateIds.add(id));
363
492
  }
364
493
 
365
- // Filter candidates to events that actually overlap with the requested date
366
- const allEvents = [];
367
- const startOfDay = new Date(date);
368
- startOfDay.setHours(0, 0, 0, 0);
369
- const endOfDay = new Date(date);
370
- endOfDay.setHours(23, 59, 59, 999);
494
+ return candidateIds;
495
+ }
371
496
 
372
- for (const id of candidateIds) {
373
- const event = this.events.get(id);
374
- if (event) {
375
- // Check if event actually occurs on the requested date in the given timezone
376
- const eventStartLocal = event.getStartInTimezone(timezone);
377
- const eventEndLocal = event.getEndInTimezone(timezone);
497
+ /**
498
+ * Keep the events that overlap a day in the given timezone, sorted by start.
499
+ * @param {Event[]} events - Candidate events
500
+ * @param {Date} dayStart - Start of the day
501
+ * @param {Date} dayEnd - End of the day
502
+ * @param {string} timezone - Timezone deciding whether an event falls on the day
503
+ * @returns {Event[]} Events on the day, sorted
504
+ * @private
505
+ */
506
+ _selectEventsForDay(events, dayStart, dayEnd, timezone) {
507
+ const onDay = events.filter(event => {
508
+ // Event overlaps with this day if it starts before end of day and ends after start of day
509
+ const eventStartLocal = event.getStartInTimezone(timezone);
510
+ const eventEndLocal = event.getEndInTimezone(timezone);
511
+ return eventStartLocal <= dayEnd && eventEndLocal >= dayStart;
512
+ });
378
513
 
379
- // Event overlaps with this day if it starts before end of day and ends after start of day
380
- if (eventStartLocal <= endOfDay && eventEndLocal >= startOfDay) {
381
- allEvents.push(event);
382
- }
383
- }
384
- }
514
+ return onDay.sort(this._compareByStart(timezone));
515
+ }
385
516
 
386
- return allEvents.sort((a, b) => {
387
- // Sort by start time in the specified timezone
388
- const aStart = a.getStartInTimezone(timezone);
389
- const bStart = b.getStartInTimezone(timezone);
390
- const timeCompare = aStart - bStart;
517
+ /**
518
+ * Comparator ordering events by start time in a timezone, longer events first.
519
+ * @param {string} timezone - Timezone used for the start comparison
520
+ * @returns {(a: Event, b: Event) => number} Comparator
521
+ * @private
522
+ */
523
+ _compareByStart(timezone) {
524
+ return (a, b) => {
525
+ const timeCompare = a.getStartInTimezone(timezone) - b.getStartInTimezone(timezone);
391
526
  if (timeCompare !== 0) return timeCompare;
392
527
  return b.duration - a.duration; // Longer events first
393
- });
528
+ };
394
529
  }
395
530
 
396
531
  /**
@@ -466,11 +601,19 @@ export class EventStore {
466
601
  * @returns {Array<Event[]>} Array of event groups that overlap
467
602
  */
468
603
  getOverlapGroups(date, timedOnly = true) {
469
- let events = this.getEventsForDate(date);
604
+ return this.groupOverlappingEvents(this.getEventsForDate(date), timedOnly);
605
+ }
470
606
 
471
- if (timedOnly) {
472
- events = events.filter(e => !e.allDay);
473
- }
607
+ /**
608
+ * Group a list of events into clusters of overlapping time slots
609
+ * Same result as {@link EventStore#getOverlapGroups} for events already fetched
610
+ * (for example one day of {@link EventStore#getEventsByDate}).
611
+ * @param {Event[]} events - Events to group; the array is not modified
612
+ * @param {boolean} [timedOnly=true] - Only include timed events (not all-day)
613
+ * @returns {Array<Event[]>} Array of event groups that overlap
614
+ */
615
+ groupOverlappingEvents(events, timedOnly = true) {
616
+ events = timedOnly ? events.filter(e => !e.allDay) : [...events];
474
617
 
475
618
  if (events.length === 0) return [];
476
619
 
@@ -632,6 +775,16 @@ export class EventStore {
632
775
 
633
776
  /**
634
777
  * Expand a recurring event into individual occurrences
778
+ *
779
+ * Returns every occurrence that overlaps the range, including ones that
780
+ * start before it but run into it (multi-day series). Each occurrence is an
781
+ * {@link Event} cloned from the master with:
782
+ * - `id` from {@link Event.occurrenceId} (`<masterId>_<startMs>`), stable
783
+ * across ranges and resolvable with {@link EventStore#getEvent},
784
+ * - `isOccurrence: true`, `recurringEventId` and `occurrenceStart`,
785
+ * - `metadata.recurringEventId`, `metadata.occurrenceId` (same as `id`) and
786
+ * `metadata.occurrenceIndex` (position within this expansion).
787
+ * Non-recurring events are returned as-is in a one-element array.
635
788
  * @param {Event} event - The recurring event
636
789
  * @param {Date} rangeStart - Start of the expansion range
637
790
  * @param {Date} rangeEnd - End of the expansion range
@@ -647,27 +800,141 @@ export class EventStore {
647
800
 
648
801
  // Expand in the event's timezone for accurate recurrence calculation
649
802
  const eventTimezone = event.timeZone || timezone;
650
- const occurrences = this.recurrenceEngine.expandEvent(event, rangeStart, rangeEnd, {
803
+
804
+ // The engine selects occurrences by start, so look back one event
805
+ // duration to catch occurrences that began before the range but overlap it
806
+ const duration = Math.max(0, event.end - event.start);
807
+ const expandStart = new Date(rangeStart.getTime() - duration);
808
+ const occurrences = this.recurrenceEngine.expandEvent(event, expandStart, rangeEnd, {
651
809
  timezone: eventTimezone
652
810
  });
653
811
 
654
- return occurrences.map((occurrence, index) => {
655
- // Create a new event instance for each occurrence
656
- const occurrenceEvent = event.clone({
657
- id: `${event.id}_occurrence_${index}`,
658
- start: occurrence.start,
659
- end: occurrence.end,
660
- timeZone: occurrence.timezone || eventTimezone,
661
- metadata: {
662
- ...event.metadata,
663
- recurringEventId: event.id,
664
- occurrenceId: occurrence.id,
665
- occurrenceIndex: index
666
- }
667
- });
812
+ const expanded = [];
813
+ for (const occurrence of occurrences) {
814
+ if (occurrence.end < rangeStart || occurrence.start > rangeEnd) {
815
+ continue;
816
+ }
817
+ expanded.push(this._createOccurrence(event, occurrence, eventTimezone, expanded.length));
818
+ }
819
+
820
+ return expanded;
821
+ }
668
822
 
669
- return occurrenceEvent;
823
+ /**
824
+ * Build the Event instance for one occurrence of a recurring master.
825
+ * @param {Event} event - The recurring master
826
+ * @param {{start: Date, end: Date, timezone?: string}} occurrence - Engine occurrence
827
+ * @param {string} eventTimezone - Timezone the series was expanded in
828
+ * @param {number} index - Position within the current expansion
829
+ * @returns {Event} Occurrence event
830
+ * @private
831
+ */
832
+ _createOccurrence(event, occurrence, eventTimezone, index) {
833
+ const occurrenceStart = new Date(occurrence.start);
834
+ const id = Event.occurrenceId(event.id, occurrenceStart);
835
+
836
+ const occurrenceEvent = event.clone({
837
+ id,
838
+ start: occurrenceStart,
839
+ end: new Date(occurrence.end),
840
+ timeZone: occurrence.timezone || eventTimezone,
841
+ metadata: {
842
+ ...event.metadata,
843
+ recurringEventId: event.id,
844
+ occurrenceId: id,
845
+ occurrenceIndex: index
846
+ }
670
847
  });
848
+
849
+ occurrenceEvent.isOccurrence = true;
850
+ occurrenceEvent.recurringEventId = event.id;
851
+ occurrenceEvent.occurrenceStart = new Date(occurrenceStart);
852
+
853
+ return occurrenceEvent;
854
+ }
855
+
856
+ /**
857
+ * Lazily iterate the occurrences of a stored event in chronological order.
858
+ *
859
+ * Occurrences come one at a time from the store's recurrence engine
860
+ * (RecurrenceEngineV2 by default), so taking the next few occurrences of
861
+ * an open-ended series does not expand the series. `after` and `before`
862
+ * are exclusive unless `inclusive` is set; see
863
+ * RecurrenceEngineV2.iterateOccurrences for the full semantics. The
864
+ * expansion timezone defaults to the event's, then the store's.
865
+ *
866
+ * @example
867
+ * for (const occurrence of store.iterateOccurrences('standup', { after: new Date() })) {
868
+ * if (occurrence.start > deadline) break;
869
+ * remind(occurrence);
870
+ * }
871
+ *
872
+ * @param {string} eventId - The event ID
873
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
874
+ * @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>} Occurrences in chronological order
875
+ * @throws {Error} If no event with the ID exists
876
+ */
877
+ iterateOccurrences(eventId, options = {}) {
878
+ const query = this._occurrenceQuery(eventId, options);
879
+ return this.recurrenceEngine.iterateOccurrences(query.event, query.options);
880
+ }
881
+
882
+ /**
883
+ * First occurrence of a stored event after an instant, or null when the
884
+ * series has no occurrence after it. `after` is exclusive unless
885
+ * `options.inclusive` is set.
886
+ *
887
+ * @example
888
+ * const upcoming = store.getNextOccurrence('standup', new Date());
889
+ *
890
+ * @param {string} eventId - The event ID
891
+ * @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
892
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Further options
893
+ * @returns {import('../types.js').ExpandedOccurrence|null} The next occurrence, or null
894
+ * @throws {Error} If no event with the ID exists
895
+ */
896
+ getNextOccurrence(eventId, after = null, options = {}) {
897
+ const query = this._occurrenceQuery(eventId, options);
898
+ return this.recurrenceEngine.nextOccurrence(query.event, after, query.options);
899
+ }
900
+
901
+ /**
902
+ * The first `count` occurrences of a stored event inside a window,
903
+ * generated lazily. `count` is capped at the engine's
904
+ * MAX_OCCURRENCES_HARD_LIMIT.
905
+ *
906
+ * @example
907
+ * const nextFive = store.takeOccurrences('standup', 5, { after: new Date() });
908
+ *
909
+ * @param {string} eventId - The event ID
910
+ * @param {number} count - Maximum number of occurrences to return
911
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
912
+ * @returns {import('../types.js').ExpandedOccurrence[]} Up to `count` occurrences in chronological order
913
+ * @throws {Error} If no event with the ID exists
914
+ */
915
+ takeOccurrences(eventId, count, options = {}) {
916
+ const query = this._occurrenceQuery(eventId, options);
917
+ return this.recurrenceEngine.takeOccurrences(query.event, count, query.options);
918
+ }
919
+
920
+ /**
921
+ * Resolve an occurrence query to the stored event and its options, with
922
+ * the timezone defaulted as expandRecurringEvent does
923
+ * @param {string} eventId - The event ID
924
+ * @param {Object} options - Caller options
925
+ * @returns {{ event: Event, options: Object }}
926
+ * @throws {Error} If no event with the ID exists
927
+ * @private
928
+ */
929
+ _occurrenceQuery(eventId, options) {
930
+ const event = this.events.get(eventId);
931
+ if (!event) {
932
+ throw new Error(`Event with id ${eventId} not found`);
933
+ }
934
+ return {
935
+ event,
936
+ options: { ...options, timezone: options.timezone || event.timeZone || this.defaultTimezone }
937
+ };
671
938
  }
672
939
 
673
940
  /**
@@ -705,6 +972,118 @@ export class EventStore {
705
972
  this.commitBatch();
706
973
  }
707
974
 
975
+ /**
976
+ * Reconcile the store with a snapshot of events, applying only the differences.
977
+ *
978
+ * Compared with {@link EventStore#loadEvents} (clear + re-add everything) this:
979
+ * - keeps the existing {@link Event} instance for every entry that is
980
+ * equivalent to the stored one (identity is preserved, no notification),
981
+ * - replaces stored events whose incoming data differs (`update` change),
982
+ * - adds events whose id is not in the store (`add` change),
983
+ * - removes stored events missing from the snapshot (`remove` change),
984
+ * unless `removeMissing` is `false`,
985
+ * - emits a single `batch` notification listing those changes, or nothing at
986
+ * all when the snapshot matches the store. When called while a batch is
987
+ * already open the changes are queued on that batch instead.
988
+ *
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.
992
+ *
993
+ * @example
994
+ * // periodic server snapshot
995
+ * const { added, updated, removed } = store.reconcile(rowsFromServer);
996
+ * if (added.length || updated.length || removed.length) rerender();
997
+ *
998
+ * @param {Array<Event|import('../types.js').EventData>} events - Complete snapshot of events
999
+ * @param {import('../types.js').ReconcileOptions} [options={}] - Reconcile options
1000
+ * @returns {import('../types.js').ReconcileResult} Events that were added, updated, removed and left untouched
1001
+ * @throws {Error} If an entry fails validation or two entries share an id
1002
+ */
1003
+ reconcile(events, options = {}) {
1004
+ const { removeMissing = true, isEquivalent = Event.isEquivalent } = options;
1005
+
1006
+ if (!events || typeof events[Symbol.iterator] !== 'function') {
1007
+ throw new Error('reconcile() expects an iterable of events');
1008
+ }
1009
+ if (typeof isEquivalent !== 'function') {
1010
+ throw new Error('reconcile() option isEquivalent must be a function');
1011
+ }
1012
+
1013
+ return this.optimizer.measure('reconcile', () => {
1014
+ // Normalize and validate everything before touching the store
1015
+ /** @type {Map<string, Event>} */
1016
+ const incoming = new Map();
1017
+ for (const eventData of events) {
1018
+ const event = eventData instanceof Event ? eventData : new Event(eventData);
1019
+ if (incoming.has(event.id)) {
1020
+ throw new Error(`Duplicate event id in reconcile input: ${event.id}`);
1021
+ }
1022
+ incoming.set(event.id, event);
1023
+ }
1024
+
1025
+ /** @type {import('../types.js').ReconcileResult} */
1026
+ const result = { added: [], updated: [], removed: [], unchanged: [] };
1027
+
1028
+ // Nest inside an existing batch if one is open, otherwise own one
1029
+ const ownsBatch = !this.isBatchMode;
1030
+ if (ownsBatch) {
1031
+ this.startBatch(true);
1032
+ }
1033
+
1034
+ try {
1035
+ if (removeMissing) {
1036
+ for (const existing of Array.from(this.events.values())) {
1037
+ if (!incoming.has(existing.id)) {
1038
+ this._detachEvent(existing);
1039
+ this._queueChange({ type: 'remove', event: existing, version: ++this.version });
1040
+ result.removed.push(existing);
1041
+ }
1042
+ }
1043
+ }
1044
+
1045
+ for (const event of incoming.values()) {
1046
+ const existing = this.events.get(event.id);
1047
+ if (!existing) {
1048
+ this.events.set(event.id, event);
1049
+ this.optimizer.cache(event.id, event, 'event');
1050
+ this._indexEvent(event);
1051
+ this._queueChange({ type: 'add', event, version: ++this.version });
1052
+ result.added.push(event);
1053
+ } else if (existing === event || isEquivalent(existing, event)) {
1054
+ result.unchanged.push(existing);
1055
+ } else {
1056
+ this._replaceEvent(existing, event);
1057
+ this._queueChange({
1058
+ type: 'update',
1059
+ event,
1060
+ oldEvent: existing,
1061
+ version: ++this.version
1062
+ });
1063
+ result.updated.push({ event, oldEvent: existing });
1064
+ }
1065
+ }
1066
+
1067
+ if (result.added.length > 0) {
1068
+ // Newly indexed events may change range/query results
1069
+ this.optimizer.queryCache.clear();
1070
+ this.optimizer.dateRangeCache.clear();
1071
+ }
1072
+ } catch (error) {
1073
+ if (ownsBatch) {
1074
+ this.rollbackBatch();
1075
+ }
1076
+ throw error;
1077
+ }
1078
+
1079
+ if (ownsBatch) {
1080
+ this.commitBatch();
1081
+ }
1082
+
1083
+ return result;
1084
+ });
1085
+ }
1086
+
708
1087
  /**
709
1088
  * Subscribe to store changes
710
1089
  * @param {Function} callback - Callback function
@@ -949,6 +1328,19 @@ export class EventStore {
949
1328
  * Notify listeners of changes
950
1329
  * @private
951
1330
  */
1331
+ /**
1332
+ * Deliver a change now, or queue it when a batch is open
1333
+ * @param {import('../types.js').EventStoreChange} change - Change to deliver
1334
+ * @private
1335
+ */
1336
+ _queueChange(change) {
1337
+ if (this.isBatchMode) {
1338
+ this.batchNotifications.push(change);
1339
+ } else {
1340
+ this._notifyChange(change);
1341
+ }
1342
+ }
1343
+
952
1344
  _notifyChange(change) {
953
1345
  for (const listener of this.listeners) {
954
1346
  try {
@@ -1066,7 +1458,7 @@ export class EventStore {
1066
1458
  this.batchBackup = null;
1067
1459
 
1068
1460
  // Clear cache
1069
- this.optimizer.clearCache();
1461
+ this.clearCaches();
1070
1462
  }
1071
1463
 
1072
1464
  this.batchNotifications = [];