@forcecalendar/core 2.3.0 → 2.4.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.
@@ -12,6 +12,11 @@ export class RecurrenceEngine {
12
12
  // Hard limit to prevent resource exhaustion regardless of caller input
13
13
  static MAX_OCCURRENCES_HARD_LIMIT = 10000;
14
14
 
15
+ // Hard limit on expansion loop iterations (every occurrence stepped
16
+ // through, inside the range or not) so a rule that cannot be seeked
17
+ // arithmetically still terminates in bounded time
18
+ static MAX_ITERATIONS_HARD_LIMIT = 100000;
19
+
15
20
  // expandEvent is typically called many times with the same RRULE string
16
21
  // (every view render), so parsed rules are cached by their source string
17
22
  static _ruleCache = new Map();
@@ -19,10 +24,18 @@ export class RecurrenceEngine {
19
24
 
20
25
  /**
21
26
  * Expand a recurring event into individual occurrences
27
+ *
28
+ * Occurrences before rangeStart are skipped without being generated:
29
+ * daily, weekly and sub-daily rules seek straight to the range, so the
30
+ * cost of a query does not grow with the age of the series, and a series
31
+ * that started years before the queried window is still expanded.
32
+ *
22
33
  * @param {import('./Event.js').Event} event - The recurring event
23
34
  * @param {Date} rangeStart - Start of the expansion range
24
35
  * @param {Date} rangeEnd - End of the expansion range
25
- * @param {number} [maxOccurrences=365] - Maximum number of occurrences to generate
36
+ * @param {number} [maxOccurrences=365] - Maximum number of occurrences to return.
37
+ * Only occurrences inside the range count towards this limit; occurrences
38
+ * between the series start and rangeStart do not consume it.
26
39
  * @param {string} [timezone] - Timezone for expansion (important for DST)
27
40
  * @returns {import('../types.js').EventOccurrence[]} Array of occurrence objects with start/end dates
28
41
  */
@@ -98,6 +111,8 @@ export class RecurrenceEngine {
98
111
 
99
112
  // Work in event's timezone for accurate recurrence calculation
100
113
  const currentDate = new Date(event.start);
114
+ // Steps taken from DTSTART: RFC 5545 COUNT is measured from there,
115
+ // independent of how many occurrences fall inside the range
101
116
  let count = 0;
102
117
 
103
118
  // Track DST transitions for proper timezone handling
@@ -112,7 +127,31 @@ export class RecurrenceEngine {
112
127
  const hasExceptions = !!(rule.exceptions && rule.exceptions.length > 0);
113
128
  let currentMs = currentDate.getTime();
114
129
 
115
- while (currentMs <= rangeEndMs && count < maxOccurrences) {
130
+ // Sub-daily rules step a fixed number of milliseconds, so the span
131
+ // before the range is skipped arithmetically instead of one step at a
132
+ // time; other frequencies take few enough steps per year to just walk
133
+ const stepMs = this._fixedStepMs(rule);
134
+ if (stepMs > 0 && currentMs < rangeStartMs) {
135
+ const seek = this._seekFixedStep(
136
+ currentMs,
137
+ rangeStartMs,
138
+ rangeEndMs,
139
+ stepMs,
140
+ rule.count ? rule.count - 1 : Infinity,
141
+ cursor => this._advanceInPlace(cursor, rule)
142
+ );
143
+ currentMs = seek.ms;
144
+ count = seek.steps;
145
+ currentDate.setTime(currentMs);
146
+ }
147
+
148
+ let iterations = 0;
149
+ while (
150
+ currentMs <= rangeEndMs &&
151
+ occurrences.length < maxOccurrences &&
152
+ iterations < RecurrenceEngine.MAX_ITERATIONS_HARD_LIMIT
153
+ ) {
154
+ iterations++;
116
155
  // Check if this occurrence is within the range
117
156
  if (currentMs >= rangeStartMs) {
118
157
  const occurrenceStart = new Date(currentMs);
@@ -223,7 +262,37 @@ export class RecurrenceEngine {
223
262
  let nextEventTzTransition = tzManager.getNextTransition(eventTimezone, currentMs, rangeEndMs);
224
263
  let nextSystemTransition = this._nextSystemTransition(currentMs, rangeEndMs);
225
264
 
226
- while (currentMs <= rangeEndMs && count < maxOccurrences) {
265
+ // Seek to the last occurrence before the range. nextEventTzTransition is
266
+ // deliberately left as computed from DTSTART: if the seek passed an
267
+ // event-timezone transition, the first in-range occurrence must still
268
+ // compare its offset with lastOffset, exactly as the general loop does.
269
+ if (currentMs < rangeStartMs) {
270
+ const maxSteps = rule.count ? rule.count - 1 : Infinity;
271
+ const seek = dayDeltas
272
+ ? this._seekWeekCycle(currentMs, weekday, rangeStartMs, rangeEndMs, rule, maxSteps)
273
+ : this._seekFixedStep(
274
+ currentMs,
275
+ rangeStartMs,
276
+ rangeEndMs,
277
+ stepDays * DAY,
278
+ maxSteps,
279
+ cursor => cursor.setDate(cursor.getDate() + stepDays)
280
+ );
281
+ currentMs = seek.ms;
282
+ count = seek.steps;
283
+ nextSystemTransition = seek.nextSystemTransition;
284
+ if (dayDeltas) {
285
+ weekday = seek.weekday;
286
+ }
287
+ }
288
+
289
+ let iterations = 0;
290
+ while (
291
+ currentMs <= rangeEndMs &&
292
+ occurrences.length < maxOccurrences &&
293
+ iterations < RecurrenceEngine.MAX_ITERATIONS_HARD_LIMIT
294
+ ) {
295
+ iterations++;
227
296
  if (currentMs >= rangeStartMs) {
228
297
  const occurrenceStart = new Date(currentMs);
229
298
  const occurrenceEnd = new Date(currentMs + duration);
@@ -276,6 +345,152 @@ export class RecurrenceEngine {
276
345
  return occurrences;
277
346
  }
278
347
 
348
+ /**
349
+ * Milliseconds per step for rules whose step is a fixed duration while
350
+ * the system UTC offset is constant. Only the sub-daily frequencies are
351
+ * reported here: DAILY and WEEKLY have their own numeric loop, and the
352
+ * calendar-based frequencies take too few steps per year to need seeking.
353
+ * @param {Object} rule - Parsed recurrence rule
354
+ * @returns {number} Step length in milliseconds, or 0 when not fixed
355
+ * @private
356
+ */
357
+ static _fixedStepMs(rule) {
358
+ const interval = rule.interval;
359
+ if (!Number.isInteger(interval) || interval <= 0) {
360
+ return 0;
361
+ }
362
+ switch (rule.freq) {
363
+ case 'SECONDLY':
364
+ return interval * 1000;
365
+ case 'MINUTELY':
366
+ return interval * 60000;
367
+ case 'HOURLY':
368
+ return interval * 3600000;
369
+ default:
370
+ return 0;
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Skip the occurrences of a fixed-step rule that fall before
376
+ * rangeStartMs without visiting each one.
377
+ *
378
+ * While the system UTC offset is constant, a wall-clock step of the
379
+ * cursor is a constant number of milliseconds, so a whole run of steps
380
+ * collapses into one multiplication. The single step that crosses a
381
+ * system-timezone transition is taken with `advance` instead, so the
382
+ * cursor ends up exactly where stepping every occurrence would have put
383
+ * it. Stops at the last occurrence before rangeStartMs; the caller's loop
384
+ * takes the step into the range.
385
+ *
386
+ * @param {number} fromMs - Cursor position (an occurrence instant)
387
+ * @param {number} rangeStartMs - Seek target
388
+ * @param {number} rangeEndMs - Upper bound for transition lookup
389
+ * @param {number} stepMs - Step length while the UTC offset is constant
390
+ * @param {number} maxSteps - Steps still permitted under COUNT (Infinity if unbounded)
391
+ * @param {(cursor: Date) => void} advance - Wall-clock step, mutating the cursor
392
+ * @returns {{ ms: number, steps: number, nextSystemTransition: number }}
393
+ * Cursor position, steps taken and the next system transition after it
394
+ * @private
395
+ */
396
+ static _seekFixedStep(fromMs, rangeStartMs, rangeEndMs, stepMs, maxSteps, advance) {
397
+ let ms = fromMs;
398
+ let steps = 0;
399
+ let nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs);
400
+ if (!Number.isFinite(rangeStartMs) || !(stepMs > 0)) {
401
+ return { ms, steps, nextSystemTransition };
402
+ }
403
+ // Comparisons are written so an invalid (NaN) cursor ends the seek
404
+ while (ms < rangeStartMs && steps < maxSteps) {
405
+ const limit = Math.min(rangeStartMs, nextSystemTransition);
406
+ // Largest k with ms + k * stepMs < limit
407
+ let k = Math.ceil((limit - ms) / stepMs) - 1;
408
+ if (ms + k * stepMs >= limit) {
409
+ k--; // division rounded up
410
+ }
411
+ k = Math.min(k, maxSteps - steps);
412
+ if (k > 0) {
413
+ ms += k * stepMs;
414
+ steps += k;
415
+ continue;
416
+ }
417
+ if (ms + stepMs >= rangeStartMs) {
418
+ break; // next step lands in the range
419
+ }
420
+ // Next step crosses a system-timezone transition
421
+ const cursor = new Date(ms);
422
+ advance(cursor);
423
+ ms = cursor.getTime();
424
+ steps++;
425
+ nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs);
426
+ }
427
+ return { ms, steps, nextSystemTransition };
428
+ }
429
+
430
+ /**
431
+ * Seek for WEEKLY BYDAY rules, whose step pattern repeats every week:
432
+ * whole weeks are skipped arithmetically from any weekday in the BYDAY
433
+ * set, and single steps (identical to the expansion loop's) are only
434
+ * taken to reach the set, around system-timezone transitions and in the
435
+ * last week before the range.
436
+ *
437
+ * @param {number} fromMs - Cursor position (an occurrence instant)
438
+ * @param {number} weekday - Weekday of the cursor (Date#getDay)
439
+ * @param {number} rangeStartMs - Seek target
440
+ * @param {number} rangeEndMs - Upper bound for transition lookup
441
+ * @param {Object} rule - Parsed rule with compiled _byDaySet/_byDayDeltas
442
+ * @param {number} maxSteps - Steps still permitted under COUNT (Infinity if unbounded)
443
+ * @returns {{ ms: number, steps: number, weekday: number, nextSystemTransition: number }}
444
+ * @private
445
+ */
446
+ static _seekWeekCycle(fromMs, weekday, rangeStartMs, rangeEndMs, rule, maxSteps) {
447
+ const DAY = 86400000;
448
+ const WEEK = 7 * DAY;
449
+ const daySet = rule._byDaySet;
450
+ const dayDeltas = rule._byDayDeltas;
451
+ const stepsPerWeek = daySet.size;
452
+ let ms = fromMs;
453
+ let steps = 0;
454
+ let nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs);
455
+ if (!Number.isFinite(rangeStartMs)) {
456
+ return { ms, steps, weekday, nextSystemTransition };
457
+ }
458
+ while (ms < rangeStartMs && steps < maxSteps) {
459
+ // A week from a weekday in the set is exactly stepsPerWeek steps and
460
+ // returns to the same weekday
461
+ if (daySet.has(weekday)) {
462
+ const limit = Math.min(rangeStartMs, nextSystemTransition);
463
+ let weeks = Math.ceil((limit - ms) / WEEK) - 1;
464
+ if (ms + weeks * WEEK >= limit) {
465
+ weeks--;
466
+ }
467
+ weeks = Math.min(weeks, Math.floor((maxSteps - steps) / stepsPerWeek));
468
+ if (weeks > 0) {
469
+ ms += weeks * WEEK;
470
+ steps += weeks * stepsPerWeek;
471
+ continue;
472
+ }
473
+ }
474
+ // Single step, identical to the expansion loop
475
+ const days = dayDeltas[weekday];
476
+ const naiveMs = ms + days * DAY;
477
+ if (naiveMs >= rangeStartMs) {
478
+ break; // next step lands in the range
479
+ }
480
+ weekday = (weekday + days) % 7;
481
+ if (naiveMs >= nextSystemTransition) {
482
+ const cursor = new Date(ms);
483
+ cursor.setDate(cursor.getDate() + days);
484
+ ms = cursor.getTime();
485
+ nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs);
486
+ } else {
487
+ ms = naiveMs;
488
+ }
489
+ steps++;
490
+ }
491
+ return { ms, steps, weekday, nextSystemTransition };
492
+ }
493
+
279
494
  /**
280
495
  * Find the next system-timezone offset transition after fromMs.
281
496
  * Cached module-wide: the system timezone is fixed for the process.
@@ -4,12 +4,20 @@
4
4
  */
5
5
 
6
6
  import { TimezoneManager } from '../timezone/TimezoneManager.js';
7
+ import { RecurrenceEngine } from './RecurrenceEngine.js';
7
8
  import { RRuleParser } from './RRuleParser.js';
8
9
 
10
+ const DAY = 86400000;
11
+
9
12
  export class RecurrenceEngineV2 {
10
13
  // Hard limit to prevent resource exhaustion regardless of caller input
11
14
  static MAX_OCCURRENCES_HARD_LIMIT = 10000;
12
15
 
16
+ // Hard limit on expansion loop iterations (every occurrence stepped
17
+ // through, inside the range or not) so a rule that cannot be seeked
18
+ // arithmetically still terminates in bounded time
19
+ static MAX_ITERATIONS_HARD_LIMIT = 100000;
20
+
13
21
  constructor() {
14
22
  // Use singleton to share cache across all components
15
23
  this.tzManager = TimezoneManager.getInstance();
@@ -27,10 +35,22 @@ export class RecurrenceEngineV2 {
27
35
 
28
36
  /**
29
37
  * Expand recurring event with advanced handling
30
- * @param {Event} event - Recurring event
38
+ *
39
+ * Occurrences before rangeStart are skipped without being generated:
40
+ * daily, weekly, hourly and minutely rules seek straight to the range, so
41
+ * a series that started years before the queried window is expanded at
42
+ * the same cost as one that started yesterday.
43
+ *
44
+ * @param {import('./Event.js').Event} event - Recurring event
31
45
  * @param {Date} rangeStart - Start of expansion range
32
46
  * @param {Date} rangeEnd - End of expansion range
33
47
  * @param {Object} options - Expansion options
48
+ * @param {number} [options.maxOccurrences=365] - Maximum number of occurrences to
49
+ * return. Only occurrences inside the range count towards this limit.
50
+ * @param {boolean} [options.includeModified=true] - Apply stored instance modifications
51
+ * @param {boolean} [options.includeCancelled=false] - Return exception dates as cancelled occurrences
52
+ * @param {string} [options.timezone] - Timezone for expansion (defaults to the event's)
53
+ * @param {boolean} [options.handleDST=true] - Adjust occurrences across DST transitions
34
54
  * @returns {Array} Expanded occurrences
35
55
  */
36
56
  expandEvent(event, rangeStart, rangeEnd, options = {}) {
@@ -59,7 +79,8 @@ export class RecurrenceEngineV2 {
59
79
  const occurrences = [];
60
80
  const duration = event.end - event.start;
61
81
 
62
- // Initialize expansion state
82
+ // Initialize expansion state. `count` is the number of steps taken from
83
+ // DTSTART, which is what RFC 5545 COUNT measures.
63
84
  const state = {
64
85
  currentDate: new Date(event.start),
65
86
  count: 0,
@@ -73,8 +94,16 @@ export class RecurrenceEngineV2 {
73
94
  state.dstTransitions = this.findDSTTransitions(rangeStart, rangeEnd, timezone);
74
95
  }
75
96
 
97
+ this.seekToRange(state, rule, rangeStart, rangeEnd, timezone);
98
+
76
99
  // Expand occurrences
77
- while (state.currentDate <= rangeEnd && state.count < maxOccurrences) {
100
+ let iterations = 0;
101
+ while (
102
+ state.currentDate <= rangeEnd &&
103
+ occurrences.length < maxOccurrences &&
104
+ iterations < RecurrenceEngineV2.MAX_ITERATIONS_HARD_LIMIT
105
+ ) {
106
+ iterations++;
78
107
  if (state.currentDate >= rangeStart) {
79
108
  const occurrence = this.generateOccurrence(
80
109
  event,
@@ -144,6 +173,77 @@ export class RecurrenceEngineV2 {
144
173
  return this.cloneOccurrences(occurrences);
145
174
  }
146
175
 
176
+ /**
177
+ * Move the expansion cursor to the last occurrence before the range
178
+ * without stepping through every occurrence in between.
179
+ *
180
+ * Applies to rules whose step is a fixed duration between system-timezone
181
+ * transitions (plain DAILY and WEEKLY, HOURLY, MINUTELY); the step that
182
+ * crosses a transition is taken with getNextDate so the result is exactly
183
+ * what stepping from DTSTART would produce. Never seeks past UNTIL, and
184
+ * counts skipped steps against COUNT.
185
+ *
186
+ * @param {Object} state - Expansion state (currentDate and count are updated)
187
+ * @param {Object} rule - Parsed recurrence rule
188
+ * @param {Date} rangeStart - Start of expansion range
189
+ * @param {Date} rangeEnd - End of expansion range
190
+ * @param {string} timezone - Expansion timezone
191
+ */
192
+ seekToRange(state, rule, rangeStart, rangeEnd, timezone) {
193
+ const stepMs = this.getFixedStepMs(rule);
194
+ if (stepMs <= 0) {
195
+ return;
196
+ }
197
+ let targetMs = rangeStart.getTime();
198
+ if (rule.until) {
199
+ // Rule objects may carry UNTIL as a string; an unparseable value
200
+ // compares false and leaves the target alone
201
+ const untilMs = new Date(rule.until).getTime();
202
+ if (untilMs < targetMs) {
203
+ targetMs = untilMs;
204
+ }
205
+ }
206
+ const fromMs = state.currentDate.getTime();
207
+ if (!(fromMs < targetMs)) {
208
+ return;
209
+ }
210
+ const seek = RecurrenceEngine._seekFixedStep(
211
+ fromMs,
212
+ targetMs,
213
+ rangeEnd.getTime(),
214
+ stepMs,
215
+ rule.count ? rule.count - 1 : Infinity,
216
+ cursor => cursor.setTime(this.getNextDate(cursor, rule, timezone, state).getTime())
217
+ );
218
+ state.currentDate = new Date(seek.ms);
219
+ state.count = seek.steps;
220
+ }
221
+
222
+ /**
223
+ * Milliseconds per step for rules getNextDate advances by a fixed
224
+ * duration while the system UTC offset is constant
225
+ * @param {Object} rule - Parsed recurrence rule
226
+ * @returns {number} Step length in milliseconds, or 0 when not fixed
227
+ */
228
+ getFixedStepMs(rule) {
229
+ const interval = rule.interval;
230
+ if (!Number.isInteger(interval) || interval <= 0) {
231
+ return 0;
232
+ }
233
+ switch (rule.freq) {
234
+ case 'DAILY':
235
+ return rule.byHour && rule.byHour.length > 0 ? 0 : interval * DAY;
236
+ case 'WEEKLY':
237
+ return rule.byDay && rule.byDay.length > 0 ? 0 : 7 * interval * DAY;
238
+ case 'HOURLY':
239
+ return interval * 3600000;
240
+ case 'MINUTELY':
241
+ return interval * 60000;
242
+ default:
243
+ return 0;
244
+ }
245
+ }
246
+
147
247
  /**
148
248
  * Generate a single occurrence with timezone handling
149
249
  */
package/core/index.js CHANGED
@@ -32,7 +32,7 @@ export { ConflictDetector } from './conflicts/ConflictDetector.js';
32
32
  export { EnhancedCalendar } from './integration/EnhancedCalendar.js';
33
33
 
34
34
  // Version — keep in sync with package.json
35
- export const VERSION = '2.3.0';
35
+ export const VERSION = '2.4.0';
36
36
 
37
37
  // Default export
38
38
  export { Calendar as default } from './calendar/Calendar.js';
package/core/types.js CHANGED
@@ -275,13 +275,55 @@
275
275
 
276
276
  /**
277
277
  * @typedef {Object} EventStoreChange
278
- * @property {('add'|'update'|'remove'|'clear')} type - Type of change
278
+ * @property {('add'|'update'|'remove'|'clear'|'batch')} type - Type of change
279
279
  * @property {import('./events/Event.js').Event} [event] - Affected event
280
280
  * @property {import('./events/Event.js').Event} [oldEvent] - Previous event state (for updates)
281
281
  * @property {import('./events/Event.js').Event[]} [oldEvents] - Previous events (for clear)
282
+ * @property {EventStoreChange[]} [changes] - Individual changes (for batch)
283
+ * @property {number} [count] - Number of individual changes (for batch)
282
284
  * @property {number} version - Store version number
283
285
  */
284
286
 
287
+ /**
288
+ * @typedef {(a: import('./events/Event.js').Event, b: import('./events/Event.js').Event) => boolean} EventEquivalenceFn
289
+ */
290
+
291
+ /**
292
+ * @typedef {Object} ReconcileOptions
293
+ * @property {boolean} [removeMissing=true] - Remove stored events that are absent from the snapshot
294
+ * @property {EventEquivalenceFn} [isEquivalent] - Comparator deciding whether a stored event is unchanged (defaults to Event.isEquivalent)
295
+ */
296
+
297
+ /**
298
+ * @typedef {Object} ReconciledUpdate
299
+ * @property {import('./events/Event.js').Event} event - Event now in the store
300
+ * @property {import('./events/Event.js').Event} oldEvent - Event it replaced
301
+ */
302
+
303
+ /**
304
+ * @typedef {Object} ReconcileResult
305
+ * @property {import('./events/Event.js').Event[]} added - Events that were not in the store before
306
+ * @property {ReconciledUpdate[]} updated - Events whose data changed
307
+ * @property {import('./events/Event.js').Event[]} removed - Events removed from the store
308
+ * @property {import('./events/Event.js').Event[]} unchanged - Stored events left untouched (same instances)
309
+ */
310
+
311
+ /**
312
+ * @typedef {Object} SetEventsOptions
313
+ * @property {boolean} [reconcile=false] - Apply only the differences instead of clearing and re-adding
314
+ * @property {boolean} [removeMissing=true] - Reconcile only: remove stored events absent from the snapshot
315
+ * @property {EventEquivalenceFn} [isEquivalent] - Reconcile only: custom equivalence comparator
316
+ */
317
+
318
+ /**
319
+ * @typedef {Object} EventsSetPayload
320
+ * @property {import('./events/Event.js').Event[]} events - All events after the operation
321
+ * @property {import('./events/Event.js').Event[]} added - Events added by the operation
322
+ * @property {ReconciledUpdate[]} updated - Events replaced by the operation
323
+ * @property {import('./events/Event.js').Event[]} removed - Events removed by the operation
324
+ * @property {import('./events/Event.js').Event[]} unchanged - Events left untouched
325
+ */
326
+
285
327
  /**
286
328
  * @typedef {Object} QueryFilters
287
329
  * @property {Date} [start] - Start date for range query
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forcecalendar/core",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "A modern, lightweight, framework-agnostic calendar engine optimized for Salesforce",
@@ -110,9 +110,66 @@ export declare class Calendar {
110
110
  getEvents(): Event[];
111
111
  /**
112
112
  * Set all events (replaces existing)
113
- * @param {Event[]} events - Array of events
114
- */
115
- setEvents(events: Event[]): void;
113
+ *
114
+ * By default this clears the store and re-adds every entry, so every stored
115
+ * {@link Event} instance is replaced. Pass `{ reconcile: true }` to apply only
116
+ * the differences instead (see {@link Calendar#reconcileEvents}).
117
+ *
118
+ * Emits a single `eventsSet` event whose payload lists the resulting
119
+ * `events` plus the `added`, `updated`, `removed` and `unchanged` sets, so
120
+ * listeners can tell a snapshot load apart from user mutations (no
121
+ * `eventAdd`/`eventUpdate`/`eventRemove` events are emitted).
122
+ *
123
+ * @example
124
+ * calendar.on('eventsSet', ({ added, updated, removed }) => {
125
+ * if (added.length || updated.length || removed.length) render();
126
+ * });
127
+ * calendar.setEvents(snapshot, { reconcile: true });
128
+ *
129
+ * @param {Array<import('../events/Event.js').Event|import('../types.js').EventData>} events - Array of events
130
+ * @param {import('../types.js').SetEventsOptions} [options={}] - Load options
131
+ * @returns {import('../types.js').EventsSetPayload} The applied change set
132
+ */
133
+ setEvents(events: Array<import('../events/Event.js').Event | import('../types.js').EventData>, options?: import('../types.js').SetEventsOptions): import('../types.js').EventsSetPayload;
134
+ /**
135
+ * Reconcile the calendar with a snapshot of events, applying only the differences.
136
+ *
137
+ * Intended for consumers that receive periodic full snapshots (polling a
138
+ * server, a reactive `events` prop). Unchanged events keep their existing
139
+ * {@link Event} instance, changed ones are replaced, new ones are added and
140
+ * events missing from the snapshot are removed (unless
141
+ * `removeMissing: false`). Equivalence is decided by
142
+ * {@link Event.isEquivalent} unless an `isEquivalent` comparator is supplied.
143
+ * Plain event data without a `timeZone` defaults to the calendar timezone,
144
+ * exactly as with {@link Calendar#addEvent}.
145
+ *
146
+ * The store emits one `eventStoreChange` of type `batch` (or none when
147
+ * nothing differs) and the calendar emits a single `eventsSet` event
148
+ * carrying the change set. Per-event `eventAdd`/`eventUpdate`/`eventRemove`
149
+ * events are not emitted, so listeners that forward those to a backend are
150
+ * not triggered by a snapshot load.
151
+ *
152
+ * @example
153
+ * const { added, updated, removed, unchanged } = calendar.reconcileEvents(rows);
154
+ * updated.forEach(({ event, oldEvent }) => console.log(oldEvent.title, '->', event.title));
155
+ *
156
+ * @param {Array<import('../events/Event.js').Event|import('../types.js').EventData>} events - Complete snapshot of events
157
+ * @param {import('../types.js').ReconcileOptions} [options={}] - Reconcile options
158
+ * @returns {import('../types.js').EventsSetPayload} Resulting events and the applied change set
159
+ * @throws {Error} If an entry fails validation or two entries share an id
160
+ */
161
+ reconcileEvents(events: Array<import('../events/Event.js').Event | import('../types.js').EventData>, options?: import('../types.js').ReconcileOptions): import('../types.js').EventsSetPayload;
162
+ /**
163
+ * Get the event store's change counter
164
+ *
165
+ * The counter increases with every add/update/remove/clear and every
166
+ * committed batch, so comparing two readings is a cheap way to find out
167
+ * whether {@link Calendar#setEvents} or {@link Calendar#reconcileEvents}
168
+ * changed anything.
169
+ *
170
+ * @returns {number} Current store version
171
+ */
172
+ getEventsVersion(): number;
116
173
  /**
117
174
  * Query events with filters
118
175
  * @param {Object} filters - Query filters
@@ -156,6 +156,42 @@ export declare class Event {
156
156
  * @returns {boolean} True if events are equal
157
157
  */
158
158
  equals(other: Event): boolean;
159
+ /**
160
+ * Fields compared by {@link Event.isEquivalent}, in comparison order.
161
+ * Scalars are compared with strict equality, dates by timestamp and
162
+ * structured fields (recurrence rule, organizer, attendees, reminders,
163
+ * categories, attachments, conference data, metadata) structurally.
164
+ * @type {ReadonlyArray<string>}
165
+ */
166
+ static EQUIVALENCE_FIELDS: ReadonlyArray<string>;
167
+ /**
168
+ * Deep equivalence check over the full event data surface.
169
+ *
170
+ * Unlike {@link Event#equals} (which only looks at identity, title, dates,
171
+ * description, location, recurrence and status) this compares every field
172
+ * that can be supplied through {@link EventData}: timezones, all-day flag,
173
+ * colours, visibility, organizer, attendees, reminders, categories,
174
+ * attachments, conference data and metadata. Dates are compared by
175
+ * timestamp; structured fields are compared structurally (arrays are
176
+ * order-sensitive, object key order is ignored). Plain event data objects
177
+ * are normalized through the {@link Event} constructor before comparison so
178
+ * that `{ color: 'red' }` and `{ backgroundColor: 'red', borderColor: 'red' }`
179
+ * describe the same event. Two events with different ids are never
180
+ * equivalent.
181
+ *
182
+ * This is the default comparator used by `EventStore.reconcile()` to decide
183
+ * whether an incoming snapshot entry replaces the stored event.
184
+ *
185
+ * @example
186
+ * Event.isEquivalent(stored, { ...stored.toObject(), backgroundColor: '#f00' }); // false
187
+ * Event.isEquivalent(stored, stored.clone()); // true
188
+ *
189
+ * @param {Event|import('../types.js').EventData} a - First event or raw event data
190
+ * @param {Event|import('../types.js').EventData} b - Second event or raw event data
191
+ * @returns {boolean} True when both describe the same event data
192
+ * @throws {Error} If raw event data fails {@link Event.validate}
193
+ */
194
+ static isEquivalent(a: Event | import('../types.js').EventData, b: Event | import('../types.js').EventData): boolean;
159
195
  /**
160
196
  * Add an attendee to the event
161
197
  * @param {import('../types.js').Attendee} attendee - Attendee to add
@@ -70,12 +70,26 @@ export declare class EventStore {
70
70
  * @throws {Error} If event not found
71
71
  */
72
72
  updateEvent(eventId: string, updates: Partial<import('../types.js').EventData>): Event;
73
+ /**
74
+ * Swap a stored event for a new instance with the same id, keeping
75
+ * indices and caches in sync. Does not notify listeners.
76
+ * @param {Event} existingEvent - Event currently in the store
77
+ * @param {Event} replacement - Event instance that takes its place
78
+ * @private
79
+ */
80
+ private _replaceEvent;
73
81
  /**
74
82
  * Remove an event from the store
75
83
  * @param {string} eventId - The event ID to remove
76
84
  * @returns {boolean} True if removed, false if not found
77
85
  */
78
86
  removeEvent(eventId: string): boolean;
87
+ /**
88
+ * Remove an event from storage, caches and indices. Does not notify listeners.
89
+ * @param {Event} event - Event currently in the store
90
+ * @private
91
+ */
92
+ private _detachEvent;
79
93
  /**
80
94
  * Get an event by ID
81
95
  * @param {string} eventId - The event ID
@@ -161,6 +175,35 @@ export declare class EventStore {
161
175
  * @param {Event[]} events - Array of events or event data
162
176
  */
163
177
  loadEvents(events: Event[]): void;
178
+ /**
179
+ * Reconcile the store with a snapshot of events, applying only the differences.
180
+ *
181
+ * Compared with {@link EventStore#loadEvents} (clear + re-add everything) this:
182
+ * - keeps the existing {@link Event} instance for every entry that is
183
+ * equivalent to the stored one (identity is preserved, no notification),
184
+ * - replaces stored events whose incoming data differs (`update` change),
185
+ * - adds events whose id is not in the store (`add` change),
186
+ * - removes stored events missing from the snapshot (`remove` change),
187
+ * unless `removeMissing` is `false`,
188
+ * - emits a single `batch` notification listing those changes, or nothing at
189
+ * all when the snapshot matches the store. When called while a batch is
190
+ * already open the changes are queued on that batch instead.
191
+ *
192
+ * Input is validated up front: invalid event data or duplicate ids throw
193
+ * before the store is modified. Any error raised while applying the diff
194
+ * rolls the store back to its previous state.
195
+ *
196
+ * @example
197
+ * // periodic server snapshot
198
+ * const { added, updated, removed } = store.reconcile(rowsFromServer);
199
+ * if (added.length || updated.length || removed.length) rerender();
200
+ *
201
+ * @param {Array<Event|import('../types.js').EventData>} events - Complete snapshot of events
202
+ * @param {import('../types.js').ReconcileOptions} [options={}] - Reconcile options
203
+ * @returns {import('../types.js').ReconcileResult} Events that were added, updated, removed and left untouched
204
+ * @throws {Error} If an entry fails validation or two entries share an id
205
+ */
206
+ reconcile(events: Array<Event | import('../types.js').EventData>, options?: import('../types.js').ReconcileOptions): import('../types.js').ReconcileResult;
164
207
  /**
165
208
  * Subscribe to store changes
166
209
  * @param {Function} callback - Callback function
@@ -201,7 +244,13 @@ export declare class EventStore {
201
244
  * Notify listeners of changes
202
245
  * @private
203
246
  */
204
- private _notifyChange;
247
+ /**
248
+ * Deliver a change now, or queue it when a batch is open
249
+ * @param {import('../types.js').EventStoreChange} change - Change to deliver
250
+ * @private
251
+ */
252
+ private _queueChange;
253
+ _notifyChange(change: any): void;
205
254
  /**
206
255
  * Get store statistics
207
256
  * @returns {Object}