Skip to content

Coalesce duplicate Apple calendar occurrences - #7620

Open
Diaspar4u wants to merge 10 commits into
fastrepl:mainfrom
Diaspar4u:fix/apple-calendar-recurring-duplicates
Open

Diaspar4u wants to merge 10 commits into
fastrepl:mainfrom
Diaspar4u:fix/apple-calendar-recurring-duplicates

Conversation

@Diaspar4u

@Diaspar4u Diaspar4u commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Problem: The SQLite calendar pipeline no longer coalesced rescheduled Apple events the way the previous pipeline did. EventKit can return several active recurrence-series records that render as the same meeting occurrence, so each record became a separate SQLite row and notification.

Solution:

  • exclude explicitly cancelled provider events before synchronization
  • for Apple only, treat the same calendar, trimmed title, exact start/end instant, and all-day state as one visible occurrence
  • retain exact tracking-ID matching for ordinary updates and other providers
  • choose the most recently modified provider record as the occurrence content
  • keep the first existing match (session-linked rows are loaded first), tombstone the other rows, and migrate embedded session metadata in the same transaction

No schema or notification-only workaround is introduced.

Verification

  • pnpm -F desktop test — 4,408 tests passed
  • pnpm -F desktop typecheck
  • pnpm -F desktop i18n:check
  • pnpm exec oxlint --quiet --format=github apps/desktop/src/ — 0 errors
  • scoped ESLint and dprint checks for every changed TypeScript file
  • cargo test --locked -p calendar
  • cargo check --locked -p calendar-interface -p tauri-plugin-calendar
  • live-data replay using the current Apple event rows from the affected installation:
    • 280 source rows converged to 265 durable rows
    • 15 duplicate rows removed
    • 0 session-linked rows selected for deletion
    • second identical pass produced 0 additions and 0 deletions

@cla-assistant

cla-assistant Bot commented Sep 14, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 13 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/desktop/src/services/calendar/process/events/sync.ts">

<violation number="1" location="apps/desktop/src/services/calendar/process/events/sync.ts:28">
P1: When a legacy Apple occurrence was tombstoned before its replacement arrives, this key cannot reconcile the old row because `existing` excludes tombstones with different tracking IDs. Load tombstoned legacy candidates by stable occurrence identity before applying this match, or the replacement creates a second durable row and leaves linked sessions on the tombstone.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread apps/desktop/src/services/calendar/storage.ts
Comment thread apps/desktop/src/services/calendar/process/events/execute.ts Outdated
for (const storeEvent of existing) {
const trackingId = storeEvent.tracking_id_event;
const key = eventKey(storeEvent.calendar_id, trackingId);
const key = calendarEventKey(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a legacy Apple occurrence was tombstoned before its replacement arrives, this key cannot reconcile the old row because existing excludes tombstones with different tracking IDs. Load tombstoned legacy candidates by stable occurrence identity before applying this match, or the replacement creates a second durable row and leaves linked sessions on the tombstone.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/services/calendar/process/events/sync.ts, line 28:

<comment>When a legacy Apple occurrence was tombstoned before its replacement arrives, this key cannot reconcile the old row because `existing` excludes tombstones with different tracking IDs. Load tombstoned legacy candidates by stable occurrence identity before applying this match, or the replacement creates a second durable row and leaves linked sessions on the tombstone.</comment>

<file context>
@@ -17,27 +18,31 @@ export function syncEvents(
   for (const storeEvent of existing) {
-    const trackingId = storeEvent.tracking_id_event;
-    const key = eventKey(storeEvent.calendar_id, trackingId);
+    const key = calendarEventKey(
+      ctx.provider,
+      storeEvent.calendar_id,
</file context>

Comment thread apps/desktop/src/services/calendar/process/events/identity.ts Outdated
Comment thread apps/desktop/src/services/calendar/process/events/identity.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files (changes from recent commits).

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.

Re-trigger cubic

Comment thread apps/desktop/src/services/calendar/process/events/identity.ts Outdated
Comment thread apps/desktop/src/services/calendar/storage.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 10 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread apps/desktop/src/services/calendar/storage.ts
Comment thread apps/desktop/src/services/calendar/process/events/identity.ts Outdated
Comment thread apps/desktop/src/services/calendar/fetch/incoming.ts Outdated
Comment thread apps/desktop/src/services/calendar/process/events/sync.ts Outdated
Comment thread apps/desktop/src/services/calendar/fetch/incoming.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 14 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/desktop/src/services/calendar/process/events/sync.test.ts">

<violation number="1" location="apps/desktop/src/services/calendar/process/events/sync.test.ts:494">
P3: This test does not exercise the alias-candidate behavior named in the test. Use a legacy or detached Apple `tracking_id_event` and an input that reaches alias lookup, so the test fails if alias candidates are incorrectly deleted.</violation>
</file>

<file name="apps/desktop/src/services/calendar/fetch/existing.ts">

<violation number="1" location="apps/desktop/src/services/calendar/fetch/existing.ts:12">
P2: When Apple provides no `external_id`, legacy occurrence aliases outside the sync window are never loaded. Pass the derived Apple identity aliases to `loadEventsForSync`, otherwise rescheduled occurrences can create a second durable row.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread apps/desktop/src/services/calendar/process/events/identity.ts Outdated
return loadEventsForSync(
ctx,
incoming.map((event) => event.tracking_id_event),
incoming.flatMap((event) => (event.external_id ? [event.external_id] : [])),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When Apple provides no external_id, legacy occurrence aliases outside the sync window are never loaded. Pass the derived Apple identity aliases to loadEventsForSync, otherwise rescheduled occurrences can create a second durable row.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/services/calendar/fetch/existing.ts, line 12:

<comment>When Apple provides no `external_id`, legacy occurrence aliases outside the sync window are never loaded. Pass the derived Apple identity aliases to `loadEventsForSync`, otherwise rescheduled occurrences can create a second durable row.</comment>

<file context>
@@ -9,5 +9,6 @@ export function fetchExistingEvents(
   return loadEventsForSync(
     ctx,
     incoming.map((event) => event.tracking_id_event),
+    incoming.flatMap((event) => (event.external_id ? [event.external_id] : [])),
   );
 }
</file context>

});
});

test("does not delete an unmatched alias candidate outside the sync window", () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This test does not exercise the alias-candidate behavior named in the test. Use a legacy or detached Apple tracking_id_event and an input that reaches alias lookup, so the test fails if alias candidates are incorrectly deleted.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/services/calendar/process/events/sync.test.ts, line 494:

<comment>This test does not exercise the alias-candidate behavior named in the test. Use a legacy or detached Apple `tracking_id_event` and an input that reaches alias lookup, so the test fails if alias candidates are incorrectly deleted.</comment>

<file context>
@@ -491,4 +490,22 @@ describe("syncEvents", () => {
     });
   });
+
+  test("does not delete an unmatched alias candidate outside the sync window", () => {
+    const result = syncEvents(
+      createMockCtx(),
</file context>

@Diaspar4u
Diaspar4u marked this pull request as draft September 14, 2026 20:06
@Diaspar4u Diaspar4u changed the title Prevent duplicate Apple calendar occurrences after rescheduling Coalesce duplicate Apple calendar occurrences Sep 14, 2026
@Diaspar4u
Diaspar4u marked this pull request as ready for review September 14, 2026 20:11

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 15 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread apps/desktop/src/services/calendar/fetch/incoming.test.ts Outdated
Comment thread apps/desktop/src/services/calendar/process/events/sync.test.ts Outdated
Comment thread apps/desktop/src/services/calendar/process/events/identity.ts Outdated
Comment thread apps/desktop/src/services/calendar/process/events/execute.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 existing issue remains and 1 new issue found across 14 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/desktop/src/services/calendar/storage.ts">

<violation number="1" location="apps/desktop/src/services/calendar/storage.ts:484">
P2: When a legacy session has only `external_event_id` or JSON tracking metadata, this join leaves its title and occurrence times empty. Apple visible-key matching then cannot migrate that session to a replacement occurrence; retain a same-calendar fallback for unlinked legacy sessions.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

FROM sessions AS session
LEFT JOIN events AS event
ON event.id = session.event_id AND event.deleted_at IS NULL
ON event.id = session.event_id

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a legacy session has only external_event_id or JSON tracking metadata, this join leaves its title and occurrence times empty. Apple visible-key matching then cannot migrate that session to a replacement occurrence; retain a same-calendar fallback for unlinked legacy sessions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/services/calendar/storage.ts, line 484:

<comment>When a legacy session has only `external_event_id` or JSON tracking metadata, this join leaves its title and occurrence times empty. Apple visible-key matching then cannot migrate that session to a replacement occurrence; retain a same-calendar fallback for unlinked legacy sessions.</comment>

<file context>
@@ -430,98 +435,53 @@ export async function loadSessionsForTrackingIds(
-            )
-          )
-          AND event.deleted_at IS NULL
+          ON event.id = session.event_id
         WHERE session.deleted_at IS NULL
       ) AS session_with_event
</file context>

Comment thread apps/desktop/src/services/calendar/process/events/sync.ts Outdated
Comment thread apps/desktop/src/services/calendar/process/events/sync.test.ts
Comment thread apps/desktop/src/services/calendar/storage.test.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 existing issues remain and 1 new issue found across 14 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/desktop/src/services/calendar/storage.test.ts">

<violation number="1" location="apps/desktop/src/services/calendar/storage.test.ts:187">
P2: This test never exercises the directly linked-event path it claims to cover: the mock returns the visible fields regardless of the join, and the fixture omits `session.event_id`. Use a SQLite-backed fixture with a nonempty direct event link and distinct session/event values, or otherwise make the mock validate which event the query selects.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 2 unresolved issues already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread apps/desktop/src/services/calendar/process/events/identity.ts Outdated
);
});

test("loads visible identity fields from the directly linked event row", async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This test never exercises the directly linked-event path it claims to cover: the mock returns the visible fields regardless of the join, and the fixture omits session.event_id. Use a SQLite-backed fixture with a nonempty direct event link and distinct session/event values, or otherwise make the mock validate which event the query selects.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/services/calendar/storage.test.ts, line 187:

<comment>This test never exercises the directly linked-event path it claims to cover: the mock returns the visible fields regardless of the join, and the fixture omits `session.event_id`. Use a SQLite-backed fixture with a nonempty direct event link and distinct session/event values, or otherwise make the mock validate which event the query selects.</comment>

<file context>
@@ -184,17 +184,18 @@ describe("calendar SQLite storage", () => {
   });
 
-  test("loads recurring identity fields for linked session migration", async () => {
+  test("loads visible identity fields from the directly linked event row", async () => {
     mocks.execute.mockResolvedValue([
       {
</file context>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 existing issues remain and 1 new issue found across 14 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/desktop/src/services/calendar/process/events/sync.test.ts">

<violation number="1" location="apps/desktop/src/services/calendar/process/events/sync.test.ts:284">
P2: This replay test never loads duplicate rows into `existing`: it converts the already-coalesced `first.toAdd` result into storage. A regression that leaves pre-existing Apple aliases undeleted would still pass, so build `existing` from a fixture containing every duplicate variant and assert the duplicate IDs are tombstoned.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 2 unresolved issues already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

const first = syncEvents(createMockCtx(), syncInput({ incoming }));
expect(first.toAdd).toHaveLength(9);

const existing = first.toAdd.map((event, index) =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This replay test never loads duplicate rows into existing: it converts the already-coalesced first.toAdd result into storage. A regression that leaves pre-existing Apple aliases undeleted would still pass, so build existing from a fixture containing every duplicate variant and assert the duplicate IDs are tombstoned.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/services/calendar/process/events/sync.test.ts, line 284:

<comment>This replay test never loads duplicate rows into `existing`: it converts the already-coalesced `first.toAdd` result into storage. A regression that leaves pre-existing Apple aliases undeleted would still pass, so build `existing` from a fixture containing every duplicate variant and assert the duplicate IDs are tombstoned.</comment>

<file context>
@@ -155,159 +155,257 @@ describe("syncEvents", () => {
+    const first = syncEvents(createMockCtx(), syncInput({ incoming }));
+    expect(first.toAdd).toHaveLength(9);
+
+    const existing = first.toAdd.map((event, index) =>
+      createExistingEvent({
+        ...event,
</file context>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 existing issues remain and 1 new issue found across 14 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="crates/calendar/src/convert.rs">

<violation number="1" location="crates/calendar/src/convert.rs:287">
P1: When EventKit returns a detached occurrence with `has_recurrence_rules == false`, this branch uses only `event_identifier` instead of synthesizing the occurrence tracking ID. Handle `is_detached` here and derive the ID from the occurrence date plus the stable external/calendar-item identifier so existing sessions and rows continue to match.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 3 unresolved issues already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment on lines 287 to 293
let id = if event.has_recurrence_rules {
let date = event.occurrence_date.as_ref().unwrap_or(&event.start_date);
let day = local_date_string(date, event.time_zone.as_deref());
format!("{}:{}", event.event_identifier, day)
} else {
event.event_identifier.clone()
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When EventKit returns a detached occurrence with has_recurrence_rules == false, this branch uses only event_identifier instead of synthesizing the occurrence tracking ID. Handle is_detached here and derive the ID from the occurrence date plus the stable external/calendar-item identifier so existing sessions and rows continue to match.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/calendar/src/convert.rs, line 287:

<comment>When EventKit returns a detached occurrence with `has_recurrence_rules == false`, this branch uses only `event_identifier` instead of synthesizing the occurrence tracking ID. Handle `is_detached` here and derive the ID from the occurrence date plus the stable external/calendar-item identifier so existing sessions and rows continue to match.</comment>

<file context>
@@ -272,7 +284,18 @@ fn convert_outlook_event(event: OutlookEvent, calendar_id: &str) -> CalendarEven
 fn convert_apple_event(event: AppleEvent) -> CalendarEvent {
     let raw = serde_json::to_string(&event).unwrap_or_default();
-    let id = apple_event_id(&event);
+    let id = if event.has_recurrence_rules {
+        let date = event.occurrence_date.as_ref().unwrap_or(&event.start_date);
+        let day = local_date_string(date, event.time_zone.as_deref());
</file context>
Suggested change
let id = if event.has_recurrence_rules {
let date = event.occurrence_date.as_ref().unwrap_or(&event.start_date);
let day = local_date_string(date, event.time_zone.as_deref());
format!("{}:{}", event.event_identifier, day)
} else {
event.event_identifier.clone()
};
let id = if event.has_recurrence_rules {
let date = event.occurrence_date.as_ref().unwrap_or(&event.start_date);
let day = local_date_string(date, event.time_zone.as_deref());
format!("{}:{}", event.event_identifier, day)
} else if event.is_detached {
let date = event.occurrence_date.as_ref().unwrap_or(&event.start_date);
let day = local_date_string(date, event.time_zone.as_deref());
let stable_id = [
event.external_identifier.as_str(),
event.calendar_item_identifier.as_str(),
event.event_identifier.as_str(),
]
.into_iter()
.find(|value| !value.is_empty())
.unwrap_or_default();
format!("{stable_id}:{day}")
} else {
event.event_identifier.clone()
};

@Diaspar4u

Copy link
Copy Markdown
Contributor Author

The detached-ID finding is intentionally resolved by preserving EventKit’s native identifier. Live EventKit records already encode the occurrence as /RID=<Apple-reference-date seconds>; synthesizing a different external/calendar-item ID was the identity assumption that failed live. Cross-series coalescing now occurs after conversion through the explicit visible-occurrence key, while the native RID remains available as an exact legacy alias. Commit 1bbfc15 adds a focused regression for this contract.

@ComputelessComputer ComputelessComputer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for digging into this. The Rust-side direction (identify an Apple occurrence by iCal UID + original occurrence date) is sound, but the TypeScript reconciliation for rows that already exist in SQLite is built on an incorrect assumption about how EventKit composes eventIdentifier, and that turns the upgrade path into the bug this PR is fixing.

Blocking

1. Legacy reconciliation does not match real EventKit identifiers

identity.ts and the new tests assume legacy IDs look like {external_id}:{series_id}:{date}. Real rows written by the current converter look like this (checked against a production app.db, 52 recurring + 7 detached Apple rows, all the same layout):

recurring: {storeUUID}:{externalIdentifier}:{YYYY-MM-DD}
detached : {storeUUID}:{externalIdentifier}/RID={secondsSince2001}

The second segment is calendarItemExternalIdentifier, and that is also exactly what recurrence_series_id holds: series_id() in crates/apple-calendar/src/apple/recurrence.rs returns the external identifier (falling back to calendarItemIdentifier, same as external_identifier). So for Apple, recurrence_series_id === external_id is an invariant. The series-marker branch therefore strips the external ID and keeps the store UUID, which is the opposite of what is needed. Feeding real shapes through calendarEventKey:

legacy recurring key : cal-1\0{storeUUID}:2026-06-01
incoming recurring   : cal-1\0{externalId}:2026-06-01   -> no match
legacy detached key  : cal-1\0{storeUUID}:2026-06-03    (started_at day of the rescheduled instance)
incoming detached    : cal-1\0{externalId}:2026-06-01   -> no match
two different series on the same day -> identical legacy key

Effect on the first sync after upgrade: every legacy Apple recurring/detached row lands in toDelete, a fresh row with a new events.id is inserted, and syncSessionEmbeddedEvents misses for the same reason, so sessions.event_id keeps pointing at a tombstone. useSessionCalendarEvent joins on deleted_at IS NULL, so those notes lose their event card and the new event row shows no linked note. That is the duplicate-note experience this PR is trying to remove, applied to every existing Apple user at once.

2. Per-occurrence ignores break

ignored_events stores tracking_id (apps/desktop/src/calendar/ignored-events.ts) and checkEventNotifications checks ignoredIds.has(event.tracking_id_event). Changing the ID format un-mutes every Apple occurrence a user has ignored. Series-level ignores survive because they key on recurrence_series_id.

Suggested direction

Either option removes the string-surgery heuristics:

  • Minimal, no migration: keep recurring IDs as {eventIdentifier}:{day} and, for is_detached, strip the /RID=... suffix from event_identifier before appending the occurrence day. A detached eventIdentifier is {master eventIdentifier}/RID=n and occurrence_date is the original date, so the detached instance resolves to the exact ID the master occurrence already had. Existing rows, sessions, and ignores stay valid.
  • UID identity with an explicit bridge: if UID-based identity is wanted (it does cover a store-UUID change), have Rust emit the legacy ID alongside the new one (e.g. CalendarEvent.legacy_ids) and match existing rows, sessions, and ignored IDs by exact tracking_id_event IN (id, legacy_ids...), then rewrite them. Exact match, testable from fixtures, and removable after a few releases.

Non-blocking

  • Please add tests that use real ID shapes ({UUID}:{uid}:{date}, {UUID}:{uid}/RID=n) in sync.test.ts, execute.test.ts, and the Rust apple_identity_tests; the current synthetic shapes are what let this slip through.
  • crates/calendar-interface/src/lib.rs:48 still documents eventIdentifier:YYYY-MM-DD. plugins/calendar/js/bindings.gen.ts carries the same doc comment and needs regenerating through codegen rather than by hand.
  • The status === "cancelled" filter applies to every provider. Outlook cancelled meetings currently sync and will disappear after this change. Probably desirable, but please call it out in the description so it lands in the changelog deliberately.
  • Detached occurrences still get recurrence_series_id = '' (pre-existing). With series-based identity it would be natural to set recurring_event_id for is_detached too, so series-level ignore covers rescheduled occurrences.
  • include_str!("../../apple-calendar/src/fixture/...") couples the two crates' directory layout. Acceptable, but a fixture inside crates/calendar would be sturdier.

Cubic triage

#4 (started_at is not the original occurrence date) is valid but subsumed by the layout issue above. #1 and #3 are minor and mostly pre-existing. #2 describes behavior already on main (the tracking-id fallback was never calendar-scoped). #5 does not apply to real ID shapes.

Process

The CLA is still unsigned so no CI has run. Note that cargo test -p calendar only runs in the scheduled macOS native job, so PR CI will not exercise the Rust change even after workflows are approved.

Comment on lines +27 to +31
!/:\d{4}-\d{2}-\d{2}$/.test(event.tracking_id_event))
) {
return event.tracking_id_event;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumes the legacy ID is {external_id}:{series_id}:{date}. Real rows are {storeUUID}:{externalIdentifier}:{date}, and for Apple recurrence_series_id is the external identifier (series_id() in crates/apple-calendar/src/apple/recurrence.rs). So this branch returns {storeUUID}:{date}, which never equals the incoming {externalId}:{date} key, and every recurring series in the same store collapses to one key per day.

If legacy rows must be re-keyed in TS, ${event.recurrence_series_id}:${occurrence} is the correct stable ID for a legacy recurring row and needs no string surgery on the UUID. See the review body for the alternative of not re-keying at all.

Comment on lines +35 to +37
if (!occurrence) return event.tracking_id_event;

const externalId = event.external_id?.trim();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same layout problem for detached rows: {storeUUID}:{externalIdentifier}/RID=n sliced at the first : keeps the store UUID and drops the external identifier. On top of that occurrence came from started_at, which is the rescheduled date, while the incoming key uses the original occurrence_date. Neither half of the derived key can match the incoming one.

started_at: session.startedAt,
}),
)
: undefined) ?? incomingByTrackingId.get(session.trackingId);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the derived key for a legacy session ({storeUUID}:{date}) never equals an incoming key, this lookup misses for every existing Apple recurring/detached session and falls through to the raw tracking-id match, which also misses since the format changed. Net result: sessions.event_id stays on the row that syncEvents just tombstoned, and useSessionCalendarEvent (joins on deleted_at IS NULL) stops showing the event for that note.

Comment thread crates/calendar/src/convert.rs Outdated
.find(|value| !value.is_empty())
.unwrap_or_default();

format!("{stable_id}:{day}")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UID + occurrence-date identity is the right model, but it re-keys every existing Apple recurring row and there is no exact-match bridge for the old IDs (see review body).

A lower-risk alternative that still fixes the reschedule duplicate: keep {event_identifier}:{day} for recurring events and, when is_detached, strip the /RID=... suffix from event_identifier before appending the day. A detached eventIdentifier is {master eventIdentifier}/RID=n and occurrence_date is the original date, so the detached instance resolves to the same ID the master occurrence already had, and nothing in SQLite, sessions, or ignored_events needs migrating.

If you do keep UID identity, please also emit the legacy ID from here (e.g. a legacy_ids field) so TS can match by exact string instead of parsing.

expect(result.toAdd).toEqual([]);
});

test("keeps one durable row when EventKit replaces a recurring series id", () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This scenario (same external_id, different recurrence_series_id) cannot occur for Apple: recurring_event_id is recurrence.series_identifier, which series_id() derives from calendarItemExternalIdentifier, the same value as external_id. Could you replace these fixtures with real-shaped IDs ({UUID}:{uid}:{date} for recurring, {UUID}:{uid}/RID=n for detached, recurrence_series_id === uid)? With those inputs the current implementation fails to reconcile, which is the case that matters for the upgrade path.

const participants: IncomingParticipants = new Map();

for (const calendarEvent of calendarEvents) {
if (calendarEvent.status === "cancelled") continue;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is provider-wide: Outlook events with isCancelled currently sync and will disappear from Anarlog after this change, and any session linked to one gets its event tombstoned. That is probably the behavior we want, but please mention it in the PR description so it is a deliberate, changelog-visible change rather than a side effect of the Apple fix.

@ComputelessComputer ComputelessComputer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes: the current Apple visible-occurrence identity can delete legitimate independent meetings. Reproduced against head 1bbfc15 with two distinct provider IDs in one calendar sharing the title Interview and the same start/end. buildIncomingEventIndex returns one canonical event; syncEvents then tombstones row-1 and rewrites row-0 to the other provider ID. Current main retains both identities. Please build on the provider-identity work already merged in #7625 and add a regression preserving independent overlapping meetings. The original duplicate-event symptom is real, but matching display fields is not sufficient evidence that two meetings are the same.

const endedAt = normalizedInstant(event.ended_at);
if (title !== undefined && startedAt && endedAt) {
return [
calendarId,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve distinct provider identities when display fields match. This key ignores tracking_id_event whenever title and dates are present. Two independently created Interview events in the same calendar at the same time produce one canonical entry. Running the current syncEvents with both existing rows deletes the second and rewrites the first to the selected provider ID. Only coalesce when provider occurrence identity or verified aliases prove equivalence; identical display fields alone must not delete a meeting.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants