Repository: takuoki/google-calendar-sync
Pull request #6: 定期的な予定を同期できるようにする
Author: login: takuoki
Created: 2025-05-11T06:16:12Z | Merged: 2025-05-18T09:10:56Z

#5 


Diff:
diff --git a/api/domain/constant/constant.go b/api/domain/constant/constant.go
new file mode 100644
index 0000000..c5f2090
--- /dev/null
+++ b/api/domain/constant/constant.go
@@ -0,0 +1,3 @@
+package constant
+
+const EventStatusCancelled = "cancelled"
diff --git a/api/domain/entity/event.go b/api/domain/entity/event.go
index 4680801..3a85bc6 100644
--- a/api/domain/entity/event.go
+++ b/api/domain/entity/event.go
@@ -3,41 +3,48 @@ package entity
 import (
 	"time"
 
+	"github.com/takuoki/google-calendar-sync/api/domain/constant"
 	"github.com/takuoki/google-calendar-sync/api/domain/valueobject"
 )
 
 type Event struct {
-	ID         valueobject.EventID
-	CalendarID valueobject.CalendarID
-	Summary    string
-	Start      *time.Time
-	End        *time.Time
-	Status     string
+	CalendarID       valueobject.CalendarID
+	ID               valueobject.EventID
+	RecurringEventID *valueobject.EventID
+	Summary          string
+	Start            *time.Time
+	End              *time.Time
+	Status           string
+}
+
+// NewEventFromRecurringEvent creates a new Event from a RecurringEvent.
+// When a RecurringEvent is updated to a normal Event, the original RecurringEvent is treated as cancelled.
+// This function is used to represent that state.
+func NewCancelledEventFromRecurringEvent(recurringEvent RecurringEvent) Event {
+	return Event{
+		CalendarID:       recurringEvent.CalendarID,
+		ID:               recurringEvent.ID,
+		RecurringEventID: nil,
+		Summary:          recurringEvent.Summary,
+		Start:            recurringEvent.Start,
+		End:              recurringEvent.End,
+		Status:           constant.EventStatusCancelled,
+	}
 }
 
 func (e *Event) Equals(other *Event) bool {
-	if e == other {
+	if e == nil && other == nil {
 		return true
 	}
-	if other == nil {
+	if e == nil || other == nil {
 		return false
 	}
-	return e.ID == other.ID &&
-		e.CalendarID == other.CalendarID &&
+
+	return e.CalendarID == other.CalendarID &&
+		e.ID == other.ID &&
+		comparePointer(e.RecurringEventID, other.RecurringEventID) &&
 		e.Summary == other.Summary &&
 		compareTime(e.Start, other.Start) &&
 		compareTime(e.End, other.End) &&
 		e.Status == other.Status
 }
-
-// compareTime is a helper function to compare two *time.Time values.
-func compareTime(t1, t2 *time.Time) bool {
-	if t1 == nil && t2 == nil {
-		return true
-	}
-	if t1 != nil && t2 != nil {
-		return t1.Equal(*t2)
-	}
-
-	return false
-}
diff --git a/api/domain/entity/event_test.go b/api/domain/entity/event_test.go
index ac865c3..66e45af 100644
--- a/api/domain/entity/event_test.go
+++ b/api/domain/entity/event_test.go
@@ -31,35 +31,56 @@ func TestEvent_Equals(t *testing.T) {
 		},
 		"equal events": {
 			event1: &entity.Event{
-				ID:         valueobject.EventID("1"),
+				CalendarID:       valueobject.CalendarID("cal1"),
+				ID:               valueobject.EventID("1"),
+				RecurringEventID: valueobject.NewEventID("r1"),
+				Summary:          "Meeting",
+				Start:            &now,
+				End:              &otherTime,
+				Status:           "confirmed",
+			},
+			event2: &entity.Event{
+				CalendarID:       valueobject.CalendarID("cal1"),
+				ID:               valueobject.EventID("1"),
+				RecurringEventID: valueobject.NewEventID("r1"),
+				Summary:          "Meeting",
+				Start:            &now,
+				End:              &otherTime,
+				Status:           "confirmed",
+			},
+			expected: true,
+		},
+		"different CalendarID": {
+			event1: &entity.Event{
 				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
 				Summary:    "Meeting",
 				Start:      &now,
 				End:        &otherTime,
 				Status:     "confirmed",
 			},
 			event2: &entity.Event{
+				CalendarID: valueobject.CalendarID("cal2"),
 				ID:         valueobject.EventID("1"),
-				CalendarID: valueobject.CalendarID("cal1"),
 				Summary:    "Meeting",
 				Start:      &now,
 				End:        &otherTime,
 				Status:     "confirmed",
 			},
-			expected: true,
+			expected: false,
 		},
 		"different ID": {
 			event1: &entity.Event{
-				ID:         valueobject.EventID("1"),
 				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
 				Summary:    "Meeting",
 				Start:      &now,
 				End:        &otherTime,
 				Status:     "confirmed",
 			},
 			event2: &entity.Event{
-				ID:         valueobject.EventID("2"),
 				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("2"),
 				Summary:    "Meeting",
 				Start:      &now,
 				End:        &otherTime,
@@ -67,37 +88,39 @@ func TestEvent_Equals(t *testing.T) {
 			},
 			expected: false,
 		},
-		"different CalendarID": {
+		"different RecurringEventID": {
 			event1: &entity.Event{
-				ID:         valueobject.EventID("1"),
-				CalendarID: valueobject.CalendarID("cal1"),
-				Summary:    "Meeting",
-				Start:      &now,
-				End:        &otherTime,
-				Status:     "confirmed",
+				CalendarID:       valueobject.CalendarID("cal1"),
+				ID:               valueobject.EventID("1"),
+				RecurringEventID: valueobject.NewEventID("r1"),
+				Summary:          "Meeting",
+				Start:            &now,
+				End:              &otherTime,
+				Status:           "confirmed",
 			},
 			event2: &entity.Event{
-				ID:         valueobject.EventID("1"),
-				CalendarID: valueobject.CalendarID("cal2"),
-				Summary:    "Meeting",
-				Start:      &now,
-				End:        &otherTime,
-				Status:     "confirmed",
+				CalendarID:       valueobject.CalendarID("cal1"),
+				ID:               valueobject.EventID("1"),
+				RecurringEventID: valueobject.NewEventID("r2"),
+				Summary:          "Meeting",
+				Start:            &now,
+				End:              &otherTime,
+				Status:           "confirmed",
 			},
 			expected: false,
 		},
 		"different Summary": {
 			event1: &entity.Event{
-				ID:         valueobject.EventID("1"),
 				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
 				Summary:    "Meeting",
 				Start:      &now,
 				End:        &otherTime,
 				Status:     "confirmed",
 			},
 			event2: &entity.Event{
-				ID:         valueobject.EventID("1"),
 				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
 				Summary:    "Workshop",
 				Start:      &now,
 				End:        &otherTime,
@@ -107,16 +130,16 @@ func TestEvent_Equals(t *testing.T) {
 		},
 		"different Start": {
 			event1: &entity.Event{
-				ID:         valueobject.EventID("1"),
 				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
 				Summary:    "Meeting",
 				Start:      &now,
 				End:        &otherTime,
 				Status:     "confirmed",
 			},
 			event2: &entity.Event{
-				ID:         valueobject.EventID("1"),
 				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
 				Summary:    "Meeting",
 				Start:      &otherTime,
 				End:        &otherTime,
@@ -126,16 +149,16 @@ func TestEvent_Equals(t *testing.T) {
 		},
 		"different End": {
 			event1: &entity.Event{
-				ID:         valueobject.EventID("1"),
 				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
 				Summary:    "Meeting",
 				Start:      &now,
 				End:        &otherTime,
 				Status:     "confirmed",
 			},
 			event2: &entity.Event{
-				ID:         valueobject.EventID("1"),
 				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
 				Summary:    "Meeting",
 				Start:      &now,
 				End:        &now,
@@ -145,16 +168,16 @@ func TestEvent_Equals(t *testing.T) {
 		},
 		"different Status": {
 			event1: &entity.Event{
-				ID:         valueobject.EventID("1"),
 				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
 				Summary:    "Meeting",
 				Start:      &now,
 				End:        &otherTime,
 				Status:     "confirmed",
 			},
 			event2: &entity.Event{
-				ID:         valueobject.EventID("1"),
 				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
 				Summary:    "Meeting",
 				Start:      &now,
 				End:        &otherTime,
@@ -175,48 +198,3 @@ func TestEvent_Equals(t *testing.T) {
 		})
 	}
 }
-
-func TestCompareTime(t *testing.T) {
-	t.Parallel()
-
-	now := time.Now()
-	otherTime := now.Add(time.Hour)
-
-	tests := map[string]struct {
-		t1       *time.Time
-		t2       *time.Time
-		expected bool
-	}{
-		"both nil": {
-			t1:       nil,
-			t2:       nil,
-			expected: true,
-		},
-		"one nil": {
-			t1:       &now,
-			t2:       nil,
-			expected: false,
-		},
-		"equal times": {
-			t1:       &now,
-			t2:       &now,
-			expected: true,
-		},
-		"different times": {
-			t1:       &now,
-			t2:       &otherTime,
-			expected: false,
-		},
-	}
-
-	for name, tt := range tests {
-		t.Run(name, func(t *testing.T) {
-			t.Parallel()
-
-			result := entity.CompareTime(tt.t1, tt.t2)
-			if result != tt.expected {
-				t.Errorf("CompareTime() = %v, want %v", result, tt.expected)
-			}
-		})
-	}
-}
diff --git a/api/domain/entity/recurring_event.go b/api/domain/entity/recurring_event.go
new file mode 100644
index 0000000..bd9203c
--- /dev/null
+++ b/api/domain/entity/recurring_event.go
@@ -0,0 +1,50 @@
+package entity
+
+import (
+	"time"
+
+	"github.com/takuoki/google-calendar-sync/api/domain/constant"
+	"github.com/takuoki/google-calendar-sync/api/domain/valueobject"
+)
+
+type RecurringEvent struct {
+	CalendarID valueobject.CalendarID
+	ID         valueobject.EventID
+	Summary    string
+	Recurrence string
+	Start      *time.Time
+	End        *time.Time
+	Status     string
+}
+
+// NewCancelledRecurringEventFromEvent creates a new RecurringEvent from a Event.
+// When an Event is updated to a RecurringEvent, the original Event is treated as cancelled.
+// This function is used to represent that state.
+func NewCancelledRecurringEventFromEvent(event Event) RecurringEvent {
+	return RecurringEvent{
+		CalendarID: event.CalendarID,
+		ID:         event.ID,
+		Summary:    event.Summary,
+		Recurrence: "", // Recurrence is not set for cancelled events
+		Start:      event.Start,
+		End:        event.End,
+		Status:     constant.EventStatusCancelled,
+	}
+}
+
+func (e *RecurringEvent) Equals(other *RecurringEvent) bool {
+	if e == nil && other == nil {
+		return true
+	}
+	if e == nil || other == nil {
+		return false
+	}
+
+	return e.ID == other.ID &&
+		e.CalendarID == other.CalendarID &&
+		e.Summary == other.Summary &&
+		e.Recurrence == other.Recurrence &&
+		compareTime(e.Start, other.Start) &&
+		compareTime(e.End, other.End) &&
+		e.Status == other.Status
+}
diff --git a/api/domain/entity/recurring_event_test.go b/api/domain/entity/recurring_event_test.go
new file mode 100644
index 0000000..794e297
--- /dev/null
+++ b/api/domain/entity/recurring_event_test.go
@@ -0,0 +1,212 @@
+package entity_test
+
+import (
+	"testing"
+	"time"
+
+	"github.com/takuoki/google-calendar-sync/api/domain/entity"
+	"github.com/takuoki/google-calendar-sync/api/domain/valueobject"
+)
+
+func TestRecurringEvent_Equals(t *testing.T) {
+	t.Parallel()
+
+	now := time.Now()
+	otherTime := now.Add(time.Hour)
+
+	tests := map[string]struct {
+		event1   *entity.RecurringEvent
+		event2   *entity.RecurringEvent
+		expected bool
+	}{
+		"both nil": {
+			event1:   nil,
+			event2:   nil,
+			expected: true,
+		},
+		"one nil": {
+			event1:   &entity.RecurringEvent{},
+			event2:   nil,
+			expected: false,
+		},
+		"equal events": {
+			event1: &entity.RecurringEvent{
+				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
+				Summary:    "Meeting",
+				Recurrence: `["RRULE:FREQ=WEEKLY;BYDAY=MO"]`,
+				Start:      &now,
+				End:        &otherTime,
+				Status:     "confirmed",
+			},
+			event2: &entity.RecurringEvent{
+				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
+				Summary:    "Meeting",
+				Recurrence: `["RRULE:FREQ=WEEKLY;BYDAY=MO"]`,
+				Start:      &now,
+				End:        &otherTime,
+				Status:     "confirmed",
+			},
+			expected: true,
+		},
+		"different CalendarID": {
+			event1: &entity.RecurringEvent{
+				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
+				Summary:    "Meeting",
+				Recurrence: `["RRULE:FREQ=WEEKLY;BYDAY=MO"]`,
+				Start:      &now,
+				End:        &otherTime,
+				Status:     "confirmed",
+			},
+			event2: &entity.RecurringEvent{
+				CalendarID: valueobject.CalendarID("cal2"),
+				ID:         valueobject.EventID("1"),
+				Summary:    "Meeting",
+				Recurrence: `["RRULE:FREQ=WEEKLY;BYDAY=MO"]`,
+				Start:      &now,
+				End:        &otherTime,
+				Status:     "confirmed",
+			},
+			expected: false,
+		},
+		"different ID": {
+			event1: &entity.RecurringEvent{
+				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
+				Summary:    "Meeting",
+				Recurrence: `["RRULE:FREQ=WEEKLY;BYDAY=MO"]`,
+				Start:      &now,
+				End:        &otherTime,
+				Status:     "confirmed",
+			},
+			event2: &entity.RecurringEvent{
+				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("2"),
+				Summary:    "Meeting",
+				Recurrence: `["RRULE:FREQ=WEEKLY;BYDAY=MO"]`,
+				Start:      &now,
+				End:        &otherTime,
+				Status:     "confirmed",
+			},
+			expected: false,
+		},
+		"different Summary": {
+			event1: &entity.RecurringEvent{
+				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
+				Summary:    "Meeting",
+				Recurrence: `["RRULE:FREQ=WEEKLY;BYDAY=MO"]`,
+				Start:      &now,
+				End:        &otherTime,
+				Status:     "confirmed",
+			},
+			event2: &entity.RecurringEvent{
+				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
+				Summary:    "Workshop",
+				Recurrence: `["RRULE:FREQ=WEEKLY;BYDAY=MO"]`,
+				Start:      &now,
+				End:        &otherTime,
+				Status:     "confirmed",
+			},
+			expected: false,
+		},
+		"different Recurrence": {
+			event1: &entity.RecurringEvent{
+				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
+				Summary:    "Meeting",
+				Recurrence: `["RRULE:FREQ=WEEKLY;BYDAY=MO"]`,
+				Start:      &now,
+				End:        &otherTime,
+				Status:     "confirmed",
+			},
+			event2: &entity.RecurringEvent{
+				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
+				Summary:    "Workshop",
+				Recurrence: `["RRULE:FREQ=WEEKLY;BYDAY=MO:UNTIL=20301231"]`,
+				Start:      &now,
+				End:        &otherTime,
+				Status:     "confirmed",
+			},
+			expected: false,
+		},
+		"different Start": {
+			event1: &entity.RecurringEvent{
+				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
+				Summary:    "Meeting",
+				Recurrence: `["RRULE:FREQ=WEEKLY;BYDAY=MO"]`,
+				Start:      &now,
+				End:        &otherTime,
+				Status:     "confirmed",
+			},
+			event2: &entity.RecurringEvent{
+				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
+				Summary:    "Meeting",
+				Recurrence: `["RRULE:FREQ=WEEKLY;BYDAY=MO"]`,
+				Start:      &otherTime,
+				End:        &otherTime,
+				Status:     "confirmed",
+			},
+			expected: false,
+		},
+		"different End": {
+			event1: &entity.RecurringEvent{
+				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
+				Summary:    "Meeting",
+				Recurrence: `["RRULE:FREQ=WEEKLY;BYDAY=MO"]`,
+				Start:      &now,
+				End:        &otherTime,
+				Status:     "confirmed",
+			},
+			event2: &entity.RecurringEvent{
+				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
+				Summary:    "Meeting",
+				Recurrence: `["RRULE:FREQ=WEEKLY;BYDAY=MO"]`,
+				Start:      &now,
+				End:        &now,
+				Status:     "confirmed",
+			},
+			expected: false,
+		},
+		"different Status": {
+			event1: &entity.RecurringEvent{
+				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
+				Summary:    "Meeting",
+				Recurrence: `["RRULE:FREQ=WEEKLY;BYDAY=MO"]`,
+				Start:      &now,
+				End:        &otherTime,
+				Status:     "confirmed",
+			},
+			event2: &entity.RecurringEvent{
+				CalendarID: valueobject.CalendarID("cal1"),
+				ID:         valueobject.EventID("1"),
+				Summary:    "Meeting",
+				Recurrence: `["RRULE:FREQ=WEEKLY;BYDAY=MO"]`,
+				Start:      &now,
+				End:        &otherTime,
+				Status:     "cancelled",
+			},
+			expected: false,
+		},
+	}
+
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			result := tt.event1.Equals(tt.event2)
+			if result != tt.expected {
+				t.Errorf("Equals() = %v, want %v", result, tt.expected)
+			}
+		})
+	}
+}
diff --git a/api/domain/entity/util.go b/api/domain/entity/util.go
new file mode 100644
index 0000000..8957851
--- /dev/null
+++ b/api/domain/entity/util.go
@@ -0,0 +1,26 @@
+package entity
+
+import "time"
+
+// compareTime is a helper function to compare two *time.Time values.
+func compareTime(t1, t2 *time.Time) bool {
+	if t1 == nil && t2 == nil {
+		return true
+	}
+	if t1 != nil && t2 != nil {
+		return t1.Equal(*t2)
+	}
+
+	return false
+}
+
+func comparePointer[T comparable](p1, p2 *T) bool {
+	if p1 == nil && p2 == nil {
+		return true
+	}
+	if p1 != nil && p2 != nil {
+		return *p1 == *p2
+	}
+
+	return false
+}
diff --git a/api/domain/entity/util_test.go b/api/domain/entity/util_test.go
new file mode 100644
index 0000000..1cd1710
--- /dev/null
+++ b/api/domain/entity/util_test.go
@@ -0,0 +1,53 @@
+package entity_test
+
+import (
+	"testing"
+	"time"
+
+	"github.com/takuoki/google-calendar-sync/api/domain/entity"
+)
+
+func TestCompareTime(t *testing.T) {
+	t.Parallel()
+
+	now := time.Now()
+	otherTime := now.Add(time.Hour)
+
+	tests := map[string]struct {
+		t1       *time.Time
+		t2       *time.Time
+		expected bool
+	}{
+		"both nil": {
+			t1:       nil,
+			t2:       nil,
+			expected: true,
+		},
+		"one nil": {
+			t1:       &now,
+			t2:       nil,
+			expected: false,
+		},
+		"equal times": {
+			t1:       &now,
+			t2:       &now,
+			expected: true,
+		},
+		"different times": {
+			t1:       &now,
+			t2:       &otherTime,
+			expected: false,
+		},
+	}
+
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			result := entity.CompareTime(tt.t1, tt.t2)
+			if result != tt.expected {
+				t.Errorf("CompareTime() = %v, want %v", result, tt.expected)
+			}
+		})
+	}
+}
diff --git a/api/domain/valueobject/id.go b/api/domain/valueobject/id.go
index 8acb769..c940ce8 100644
--- a/api/domain/valueobject/id.go
+++ b/api/domain/valueobject/id.go
@@ -10,4 +10,15 @@ func (c CalendarID) ToChannelID() string {
 
 type EventID string
 
+func NewEventID(id string) *EventID {
+	if id == "" {
+		return nil
+	}
+	return pointer(EventID(id))
+}
+
 type ResourceID string
+
+func pointer[T any](v T) *T {
+	return &v
+}
diff --git a/api/repository/googlecalendar/event_repository.go b/api/repository/googlecalendar/event_repository.go
index 18176ea..27464ab 100644
--- a/api/repository/googlecalendar/event_repository.go
+++ b/api/repository/googlecalendar/event_repository.go
@@ -2,121 +2,115 @@ package googlecalendar
 
 import (
 	"context"
-	"encoding/json"
 	"fmt"
 	"time"
 
 	calendar "google.golang.org/api/calendar/v3"
-	"google.golang.org/api/googleapi"
 
 	"github.com/takuoki/golib/applog"
-	"github.com/takuoki/google-calendar-sync/api/domain"
 	"github.com/takuoki/google-calendar-sync/api/domain/entity"
 	"github.com/takuoki/google-calendar-sync/api/domain/service"
 	"github.com/takuoki/google-calendar-sync/api/domain/valueobject"
 )
 
 func (r *googleCalendarRepository) ListEventsWithAfter(
-	ctx context.Context, calendarID valueobject.CalendarID, after time.Time) ([]entity.Event, string, error) {
-	// TODO: トークン失効を考慮すると、ShowDeleted=trueで呼び出す必要がある
-	call := r.service.Events.List(string(calendarID)).Context(ctx).TimeMin(after.Format(time.RFC3339))
-	return listEvents(ctx, call, calendarID, r.clockService, r.logger)
+	ctx context.Context, calendarID valueobject.CalendarID, after time.Time) ([]entity.Event, []entity.RecurringEvent, string, error) {
+	return listEventsWithAfter(ctx, r.service, r.clockService, r.logger, calendarID, after)
 }
 
 func (r *googleCalendarWithOauthRepository) ListEventsWithAfter(
-	ctx context.Context, calendarID valueobject.CalendarID, after time.Time) ([]entity.Event, string, error) {
-	// TODO: トークン失効を考慮すると、ShowDeleted=trueで呼び出す必要がある
+	ctx context.Context, calendarID valueobject.CalendarID, after time.Time) ([]entity.Event, []entity.RecurringEvent, string, error) {
+
 	service, err := r.getCalendarService(ctx, calendarID)
 	if err != nil {
-		return nil, "", fmt.Errorf("fail to get calendar service: %w", err)
+		return nil, nil, "", fmt.Errorf("fail to get calendar service: %w", err)
 	}
 
-	call := service.Events.List(string(calendarID)).Context(ctx).TimeMin(after.Format(time.RFC3339))
-	return listEvents(ctx, call, calendarID, r.clockService, r.logger)
+	return listEventsWithAfter(ctx, service, r.clockService, r.logger, calendarID, after)
+}
+
+func listEventsWithAfter(
+	ctx context.Context, service *calendar.Service, clockService service.Clock, logger applog.Logger,
+	calendarID valueobject.CalendarID, after time.Time) ([]entity.Event, []entity.RecurringEvent, string, error) {
+
+	call := &eventsListCallWrapper{
+		call: service.Events.List(string(calendarID)).Context(ctx).
+			ShowDeleted(true).
+			TimeMin(after.Format(time.RFC3339)),
+	}
+
+	return listEvents(ctx, clockService, logger, call, calendarID)
 }
 
 func (r *googleCalendarRepository) ListEventsWithSyncToken(
-	ctx context.Context, calendarID valueobject.CalendarID, syncToken string) ([]entity.Event, string, error) {
-	call := r.service.Events.List(string(calendarID)).Context(ctx).SyncToken(syncToken)
-	return listEvents(ctx, call, calendarID, r.clockService, r.logger)
+	ctx context.Context, calendarID valueobject.CalendarID, syncToken string) ([]entity.Event, []entity.RecurringEvent, string, error) {
+	return listEventsWithSyncToken(ctx, r.service, r.clockService, r.logger, calendarID, syncToken)
 }
 
 func (r *googleCalendarWithOauthRepository) ListEventsWithSyncToken(
-	ctx context.Context, calendarID valueobject.CalendarID, syncToken string) ([]entity.Event, string, error) {
+	ctx context.Context, calendarID valueobject.CalendarID, syncToken string) ([]entity.Event, []entity.RecurringEvent, string, error) {
 
 	service, err := r.getCalendarService(ctx, calendarID)
 	if err != nil {
-		return nil, "", fmt.Errorf("fail to get calendar service: %w", err)
+		return nil, nil, "", fmt.Errorf("fail to get calendar service: %w", err)
 	}
 
-	call := service.Events.List(string(calendarID)).Context(ctx).SyncToken(syncToken)
-	return listEvents(ctx, call, calendarID, r.clockService, r.logger)
+	return listEventsWithSyncToken(ctx, service, r.clockService, r.logger, calendarID, syncToken)
 }
 
-// TODO: EventsListCall だけでなく、EventsInstancesCall も受け取れるようにする
-func listEvents(ctx context.Context, baseCall *calendar.EventsListCall,
-	calendarID valueobject.CalendarID, clockService service.Clock, logger applog.Logger) ([]entity.Event, string, error) {
+func listEventsWithSyncToken(ctx context.Context, service *calendar.Service, clockService service.Clock, logger applog.Logger,
+	calendarID valueobject.CalendarID, syncToken string) ([]entity.Event, []entity.RecurringEvent, string, error) {
 
-	pageToken := ""
-	syncToken := ""
-	res := []entity.Event{}
-	for syncToken == "" { // 最後のページまで取得すると必ず値が入る
-		if err := ctx.Err(); err != nil {
-			return nil, "", fmt.Errorf("context error: %w", err)
-		}
+	call := &eventsListCallWrapper{
+		call: service.Events.List(string(calendarID)).Context(ctx).
+			SyncToken(syncToken),
+	}
 
-		var call *calendar.EventsListCall
-		if pageToken == "" {
-			call = baseCall
-		} else {
-			call = baseCall.PageToken(pageToken)
-		}
+	return listEvents(ctx, clockService, logger, call, calendarID)
+}
 
-		events, err := call.Do()
-		if err != nil {
-			// WARNING: syncToken が古い場合については動作確認未実施
-			// see: https://pkg.go.dev/google.golang.org/api/calendar/v3#EventsListCall.SyncToken
-			if gErr, ok := err.(*googleapi.Error); ok && gErr.Code == 410 {
-				return nil, "", domain.SyncTokenIsOldError
-			}
-			return nil, "", fmt.Errorf("fail to list events: %w", err)
-		}
+func (r *googleCalendarRepository) ListEventInstancesBetween(
+	ctx context.Context, calendarID valueobject.CalendarID, eventID valueobject.EventID, from, to time.Time) ([]entity.Event, error) {
+	return listEventInstancesBetween(ctx, r.service, r.clockService, r.logger, calendarID, eventID, from, to)
+}
 
-		for _, item := range events.Items {
-
-			// TODO: この Debug ログは最終的には削除する（ログの量が多いため）
-			itemJSON, err := json.Marshal(item)
-			if err != nil {
-				return nil, "", fmt.Errorf("fail to marshal event item to JSON: %w", err)
-			}
-			logger.Debugf(ctx, "event detail: %s", string(itemJSON))
-
-			start, err := convertDateTime(item.Start, events.TimeZone)
-			if err != nil {
-				return nil, "", fmt.Errorf("fail to convert start datetime: %w", err)
-			}
-			end, err := convertDateTime(item.End, events.TimeZone)
-			if err != nil {
-				return nil, "", fmt.Errorf("fail to convert end datetime: %w", err)
-			}
-
-			res = append(res, entity.Event{
-				ID:         valueobject.EventID(item.Id),
-				CalendarID: calendarID,
-				Summary:    item.Summary,
-				Start:      start,
-				End:        end,
-				Status:     item.Status,
-			})
-		}
+func (r *googleCalendarWithOauthRepository) ListEventInstancesBetween(
+	ctx context.Context, calendarID valueobject.CalendarID, eventID valueobject.EventID, from, to time.Time) ([]entity.Event, error) {
 
-		pageToken = events.NextPageToken
-		syncToken = events.NextSyncToken
+	service, err := r.getCalendarService(ctx, calendarID)
+	if err != nil {
+		return nil, fmt.Errorf("fail to get calendar service: %w", err)
+	}
+
+	return listEventInstancesBetween(ctx, service, r.clockService, r.logger, calendarID, eventID, from, to)
+}
+
+func listEventInstancesBetween(ctx context.Context, service *calendar.Service, clockService service.Clock, logger applog.Logger,
+	calendarID valueobject.CalendarID, eventID valueobject.EventID, from, to time.Time) ([]entity.Event, error) {
+
+	call := &eventsInstancesCallWrapper{
+		call: service.Events.Instances(string(calendarID), string(eventID)).Context(ctx).
+			// 個別イベント化されていない子イベントを更新する場合は、全削除＆全登録のため、削除されたものは取得不要
+			// 個別イベント化されたイベントは、通常の listEvents で差分取得されるため、ここでは考慮不要
+			ShowDeleted(false).
+			TimeMin(from.Format(time.RFC3339)).
+			TimeMax(to.Format(time.RFC3339)),
+	}
+
+	// 子イベント取得時は差分取得ではないため、syncToken は不要
+	events, recurringEvents, _, err := listEvents(ctx, clockService, logger, call, calendarID)
+
+	if len(recurringEvents) > 0 {
+		// 子イベント取得時には定期的なイベントは取得されない想定のため、ログ出力のみ実施して返さない
+		recurringEventIDs := make([]string, 0, len(recurringEvents))
+		for _, recurringEvent := range recurringEvents {
+			recurringEventIDs = append(recurringEventIDs, string(recurringEvent.ID))
+		}
 
-		logger.Debugf(ctx, "list events: pageToken=%q, syncToken=%q", pageToken, syncToken)
+		logger.Warnf(ctx, "recurring events are found when listing event instances (eventID: %s, recurringEventIDs: %v)", eventID, recurringEventIDs)
 	}
 
-	return res, syncToken, nil
+	return events, err
 }
 
 func (r *googleCalendarRepository) Watch(ctx context.Context, calendarID valueobject.CalendarID) (*entity.Channel, error) {
diff --git a/api/repository/googlecalendar/googlecalendar.go b/api/repository/googlecalendar/googlecalendar.go
index 42de4df..0ec3a57 100644
--- a/api/repository/googlecalendar/googlecalendar.go
+++ b/api/repository/googlecalendar/googlecalendar.go
@@ -142,8 +142,7 @@ func convertDateTime(datetime *calendar.EventDateTime, location string) (*time.T
 		return &t, nil
 	}
 
-	// TODO: ここを通るケースが存在するか確認する（削除データとか？）
-	return nil, nil
+	return nil, fmt.Errorf("invalid datetime: %+v", datetime)
 }
 
 func convertUnitTime(t int64) (time.Time, error) {
diff --git a/api/repository/googlecalendar/list_events.go b/api/repository/googlecalendar/list_events.go
new file mode 100644
index 0000000..0fdd08d
--- /dev/null
+++ b/api/repository/googlecalendar/list_events.go
@@ -0,0 +1,123 @@
+package googlecalendar
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+
+	"github.com/takuoki/golib/applog"
+	"github.com/takuoki/google-calendar-sync/api/domain"
+	"github.com/takuoki/google-calendar-sync/api/domain/entity"
+	"github.com/takuoki/google-calendar-sync/api/domain/service"
+	"github.com/takuoki/google-calendar-sync/api/domain/valueobject"
+	calendar "google.golang.org/api/calendar/v3"
+	"google.golang.org/api/googleapi"
+)
+
+type listEventCall interface {
+	Do(...googleapi.CallOption) (*calendar.Events, error)
+	PageToken(string) listEventCall
+}
+
+type eventsListCallWrapper struct {
+	call *calendar.EventsListCall
+}
+
+func (w *eventsListCallWrapper) Do(opts ...googleapi.CallOption) (*calendar.Events, error) {
+	return w.call.Do(opts...)
+}
+
+func (w *eventsListCallWrapper) PageToken(token string) listEventCall {
+	w.call = w.call.PageToken(token)
+	return w
+}
+
+type eventsInstancesCallWrapper struct {
+	call *calendar.EventsInstancesCall
+}
+
+func (w *eventsInstancesCallWrapper) Do(opts ...googleapi.CallOption) (*calendar.Events, error) {
+	return w.call.Do(opts...)
+}
+
+func (w *eventsInstancesCallWrapper) PageToken(token string) listEventCall {
+	w.call = w.call.PageToken(token)
+	return w
+}
+
+func listEvents(ctx context.Context, clockService service.Clock, logger applog.Logger,
+	baseCall listEventCall, calendarID valueobject.CalendarID) ([]entity.Event, []entity.RecurringEvent, string, error) {
+
+	pageToken := ""
+	syncToken := ""
+	resEvents := []entity.Event{}
+	recurringEvents := []entity.RecurringEvent{}
+	for syncToken == "" { // 最後のページまで取得すると必ず値が入る
+		if err := ctx.Err(); err != nil {
+			return nil, nil, "", fmt.Errorf("context error: %w", err)
+		}
+
+		var call listEventCall
+		if pageToken == "" {
+			call = baseCall
+		} else {
+			call = baseCall.PageToken(pageToken)
+		}
+
+		events, err := call.Do()
+		if err != nil {
+			// WARNING: syncToken が古い場合については動作確認未実施
+			// see: https://pkg.go.dev/google.golang.org/api/calendar/v3#EventsListCall.SyncToken
+			if gErr, ok := err.(*googleapi.Error); ok && gErr.Code == 410 {
+				return nil, nil, "", domain.SyncTokenIsOldError
+			}
+			return nil, nil, "", fmt.Errorf("fail to list events: %w", err)
+		}
+
+		for _, item := range events.Items {
+
+			start, err := convertDateTime(item.Start, events.TimeZone)
+			if err != nil {
+				return nil, nil, "", fmt.Errorf("fail to convert start datetime: %w", err)
+			}
+			end, err := convertDateTime(item.End, events.TimeZone)
+			if err != nil {
+				return nil, nil, "", fmt.Errorf("fail to convert end datetime: %w", err)
+			}
+
+			if len(item.Recurrence) == 0 {
+				resEvents = append(resEvents, entity.Event{
+					ID:               valueobject.EventID(item.Id),
+					CalendarID:       calendarID,
+					RecurringEventID: valueobject.NewEventID(item.RecurringEventId),
+					Summary:          item.Summary,
+					Start:            start,
+					End:              end,
+					Status:           item.Status,
+				})
+			} else {
+				recurrenceStr, err := json.Marshal(item.Recurrence)
+				if err != nil {
+					return nil, nil, "", fmt.Errorf("fail to marshal recurrence: %w", err)
+				}
+
+				recurringEvents = append(recurringEvents, entity.RecurringEvent{
+					ID:         valueobject.EventID(item.Id),
+					CalendarID: calendarID,
+					Summary:    item.Summary,
+					Recurrence: string(recurrenceStr),
+					Start:      start,
+					End:        end,
+					Status:     item.Status,
+				})
+			}
+		}
+
+		pageToken = events.NextPageToken
+		syncToken = events.NextSyncToken
+
+		logger.Debugf(ctx, "list events: pageToken=%q, syncToken=%q", pageToken, syncToken)
+	}
+
+	return resEvents, recurringEvents, syncToken, nil
+}
diff --git a/api/repository/mysql/calendar_repository.go b/api/repository/mysql/calendar_repository.go
index de2b6fa..ecbf04c 100644
--- a/api/repository/mysql/calendar_repository.go
+++ b/api/repository/mysql/calendar_repository.go
@@ -185,31 +185,37 @@ func createCalendar(ctx context.Context, db database,
 	return nil
 }
 
-func (r *MysqlRepository) DeleteAllCalendarsForMain(ctx context.Context, m *testing.M) error {
-	err := r.deleteAllCalendars(ctx)
+func (r *MysqlRepository) DeleteAllCalendarsForMain(ctx context.Context, m *testing.M) (updatedCount int, err error) {
+	updatedCount, err = r.deleteAllCalendars(ctx)
 	if err != nil {
-		return fmt.Errorf("fail to delete all calendars: %w", err)
+		return 0, fmt.Errorf("fail to delete all calendars: %w", err)
 	}
 
-	return nil
+	return updatedCount, nil
 }
 
-func (r *MysqlRepository) DeleteAllCalendars(ctx context.Context, t *testing.T) error {
+func (r *MysqlRepository) DeleteAllCalendars(ctx context.Context, t *testing.T) (updatedCount int, err error) {
 	t.Helper()
 
-	err := r.deleteAllCalendars(ctx)
+	updatedCount, err = r.deleteAllCalendars(ctx)
 	if err != nil {
-		return fmt.Errorf("fail to delete all calendars: %w", err)
+		return 0, fmt.Errorf("fail to delete all calendars: %w", err)
 	}
 
-	return nil
+	return updatedCount, nil
 }
 
-func (r *MysqlRepository) deleteAllCalendars(ctx context.Context) error {
-	_, err := r.db.ExecContext(ctx, "DELETE FROM calendars")
+func (r *MysqlRepository) deleteAllCalendars(ctx context.Context) (updatedCount int, err error) {
+	result, err := r.db.ExecContext(ctx, "DELETE FROM calendars")
 	if err != nil {
-		return fmt.Errorf("fail to delete calendars: %w", err)
+		return 0, fmt.Errorf("fail to delete calendars: %w", err)
 	}
 
-	return nil
+	affectedRows, err := result.RowsAffected()
+	if err != nil {
+		return 0, fmt.Errorf("fail to get affected rows: %w", err)
+	}
+	updatedCount = int(affectedRows)
+
+	return updatedCount, nil
 }
diff --git a/api/repository/mysql/channel_history_repository.go b/api/repository/mysql/channel_history_repository.go
index 6f152f7..1890584 100644
--- a/api/repository/mysql/channel_history_repository.go
+++ b/api/repository/mysql/channel_history_repository.go
@@ -139,31 +139,37 @@ func (tx *mysqlTransaction) StopActiveChannels(
 	return nil
 }
 
-func (r *MysqlRepository) DeleteAllChannelHistoriesForMain(ctx context.Context, m *testing.M) error {
-	err := r.deleteAllChannelHistories(ctx)
+func (r *MysqlRepository) DeleteAllChannelHistoriesForMain(ctx context.Context, m *testing.M) (updatedCount int, err error) {
+	updatedCount, err = r.deleteAllChannelHistories(ctx)
 	if err != nil {
-		return fmt.Errorf("fail to delete all channel histories: %w", err)
+		return 0, fmt.Errorf("fail to delete all channel histories: %w", err)
 	}
 
-	return nil
+	return updatedCount, nil
 }
 
-func (r *MysqlRepository) DeleteAllChannelHistories(ctx context.Context, t *testing.T) error {
+func (r *MysqlRepository) DeleteAllChannelHistories(ctx context.Context, t *testing.T) (updatedCount int, err error) {
 	t.Helper()
 
-	err := r.deleteAllChannelHistories(ctx)
+	updatedCount, err = r.deleteAllChannelHistories(ctx)
 	if err != nil {
-		return fmt.Errorf("fail to delete all channel histories: %w", err)
+		return 0, fmt.Errorf("fail to delete all channel histories: %w", err)
 	}
 
-	return nil
+	return updatedCount, nil
 }
 
-func (r *MysqlRepository) deleteAllChannelHistories(ctx context.Context) error {
-	_, err := r.db.ExecContext(ctx, "DELETE FROM channel_histories")
+func (r *MysqlRepository) deleteAllChannelHistories(ctx context.Context) (updatedCount int, err error) {
+	result, err := r.db.ExecContext(ctx, "DELETE FROM channel_histories")
 	if err != nil {
-		return fmt.Errorf("fail to delete channel histories: %w", err)
+		return 0, fmt.Errorf("fail to delete channel histories: %w", err)
 	}
 
-	return nil
+	affectedRows, err := result.RowsAffected()
+	if err != nil {
+		return 0, fmt.Errorf("fail to get affected rows: %w", err)
+	}
+	updatedCount = int(affectedRows)
+
+	return updatedCount, nil
 }
diff --git a/api/repository/mysql/event_repository.go b/api/repository/mysql/event_repository.go
index 3f5c0de..5fa15bf 100644
--- a/api/repository/mysql/event_repository.go
+++ b/api/repository/mysql/event_repository.go
@@ -6,7 +6,9 @@ import (
 	"sort"
 	"strings"
 	"testing"
+	"time"
 
+	"github.com/takuoki/google-calendar-sync/api/domain/constant"
 	"github.com/takuoki/google-calendar-sync/api/domain/entity"
 	"github.com/takuoki/google-calendar-sync/api/domain/valueobject"
 )
@@ -61,9 +63,9 @@ func (r *MysqlRepository) CreateEvent(ctx context.Context, t *testing.T,
 func createEvent(ctx context.Context, db database, event entity.Event) error {
 	_, err := db.ExecContext(ctx,
 		"INSERT INTO events "+
-			"(id, calendar_id, summary, start, end, status) "+
-			"VALUES (?, ?, ?, ?, ?, ?)",
-		event.ID, event.CalendarID, event.Summary, event.Start, event.End, event.Status)
+			"(calendar_id, id, recurring_event_id, summary, start, end, status) "+
+			"VALUES (?, ?, ?, ?, ?, ?, ?)",
+		event.CalendarID, event.ID, event.RecurringEventID, event.Summary, event.Start, event.End, event.Status)
 	if err != nil {
 		return fmt.Errorf("fail to insert event: %w", err)
 	}
@@ -71,25 +73,20 @@ func createEvent(ctx context.Context, db database, event entity.Event) error {
 	return nil
 }
 
-func (tx *mysqlTransaction) SyncEvents(ctx context.Context, events []entity.Event) (int, error) {
+func (tx *mysqlTransaction) SyncEvents(ctx context.Context, calendarID valueobject.CalendarID, events []entity.Event) (int, error) {
 
 	if len(events) == 0 {
 		return 0, nil
 	}
 
-	calendarID := events[0].CalendarID
 	eventIDs := make([]valueobject.EventID, 0, len(events))
 	for _, event := range events {
 		if event.CalendarID != calendarID {
-			return 0, fmt.Errorf("all events must have the same calendar ID")
+			return 0, fmt.Errorf("all events must have the same calendar ID: expected %s, got %s", calendarID, event.CalendarID)
 		}
 		eventIDs = append(eventIDs, event.ID)
 	}
 
-	if err := tx.LockCalendar(ctx, calendarID); err != nil {
-		return 0, fmt.Errorf("fail to lock calendar: %w", err)
-	}
-
 	dbEventMap, err := tx.fetchEventMap(ctx, calendarID, eventIDs)
 	if err != nil {
 		return 0, err
@@ -120,10 +117,11 @@ func (tx *mysqlTransaction) SyncEvents(ctx context.Context, events []entity.Even
 		}
 
 		// DB に存在するが、内容が異なる場合は更新
-		if err := tx.updateEvent(ctx, event); err != nil {
+		cnt, err := tx.updateEvent(ctx, event)
+		if err != nil {
 			return 0, fmt.Errorf("fail to update event: %w", err)
 		}
-		updateCount++
+		updateCount += cnt
 	}
 
 	return updateCount, nil
@@ -144,7 +142,7 @@ func (tx *mysqlTransaction) fetchEventMap(ctx context.Context,
 	}
 
 	query := fmt.Sprintf(
-		"SELECT id, calendar_id, summary, start, end, status "+
+		"SELECT id, calendar_id, recurring_event_id, summary, start, end, status "+
 			"FROM events WHERE calendar_id = ? AND id IN (%s)",
 		strings.Join(placeholders, ", "),
 	)
@@ -165,6 +163,7 @@ func (tx *mysqlTransaction) fetchEventMap(ctx context.Context,
 		err := rows.Scan(
 			&event.ID,
 			&event.CalendarID,
+			&event.RecurringEventID,
 			&event.Summary,
 			&event.Start,
 			&event.End,
@@ -179,43 +178,96 @@ func (tx *mysqlTransaction) fetchEventMap(ctx context.Context,
 	return eventMap, nil
 }
 
-func (tx *mysqlTransaction) updateEvent(ctx context.Context, event entity.Event) error {
-	_, err := tx.tx.ExecContext(ctx,
-		"UPDATE events SET summary = ?, start = ?, end = ?, status = ? "+
-			"WHERE id = ? AND calendar_id = ?",
-		event.Summary, event.Start, event.End, event.Status, event.ID, event.CalendarID)
+func (tx *mysqlTransaction) updateEvent(ctx context.Context, event entity.Event) (updatedCount int, err error) {
+	result, err := tx.tx.ExecContext(ctx,
+		"UPDATE events SET recurring_event_id = ?, summary = ?, start = ?, end = ?, status = ? "+
+			"WHERE calendar_id = ? AND id = ?",
+		event.RecurringEventID, event.Summary, event.Start, event.End, event.Status, event.CalendarID, event.ID)
 	if err != nil {
-		return fmt.Errorf("fail to update event: %w", err)
+		return 0, fmt.Errorf("fail to update event: %w", err)
 	}
 
-	return nil
+	affectedRows, err := result.RowsAffected()
+	if err != nil {
+		return 0, fmt.Errorf("fail to get affected rows: %w", err)
+	}
+	updatedCount = int(affectedRows)
+
+	return updatedCount, nil
 }
 
-func (r *MysqlRepository) DeleteAllEventsForMain(ctx context.Context, m *testing.M) error {
-	err := r.deleteAllEvents(ctx)
+func (tx *mysqlTransaction) cancelEventInstancesWithAfter(ctx context.Context,
+	calendarID valueobject.CalendarID, recurringEventID valueobject.EventID, excludedEventIDs []valueobject.EventID,
+	after time.Time) (updatedCount int, err error) {
+
+	var query string
+	var args []interface{}
+
+	if len(excludedEventIDs) == 0 {
+		// excludedEventIDsが空の場合はid NOT IN 句は不要
+		query = "UPDATE events SET status = ? " +
+			"WHERE calendar_id = ? AND recurring_event_id = ? AND start >= ?"
+		args = []interface{}{constant.EventStatusCancelled, calendarID, recurringEventID, after}
+	} else {
+		placeholders := make([]string, len(excludedEventIDs))
+		for i := range excludedEventIDs {
+			placeholders[i] = "?"
+		}
+		query = "UPDATE events SET status = ? " +
+			"WHERE calendar_id = ? AND recurring_event_id = ? AND id NOT IN (" +
+			strings.Join(placeholders, ",") + ") AND start >= ?"
+		args = append(args, constant.EventStatusCancelled, calendarID, recurringEventID)
+		for _, id := range excludedEventIDs {
+			args = append(args, id)
+		}
+		args = append(args, after)
+	}
+
+	result, err := tx.tx.ExecContext(ctx, query, args...)
 	if err != nil {
-		return fmt.Errorf("fail to delete all events: %w", err)
+		return 0, fmt.Errorf("fail to update events: %w", err)
 	}
 
-	return nil
+	affectedRows, err := result.RowsAffected()
+	if err != nil {
+		return 0, fmt.Errorf("fail to get affected rows: %w", err)
+	}
+	updatedCount = int(affectedRows)
+
+	return updatedCount, nil
 }
 
-func (r *MysqlRepository) DeleteAllEvents(ctx context.Context, t *testing.T) error {
+func (r *MysqlRepository) DeleteAllEventsForMain(ctx context.Context, m *testing.M) (updatedCount int, err error) {
+	updatedCount, err = r.deleteAllEvents(ctx)
+	if err != nil {
+		return 0, fmt.Errorf("fail to delete all events: %w", err)
+	}
+
+	return updatedCount, nil
+}
+
+func (r *MysqlRepository) DeleteAllEvents(ctx context.Context, t *testing.T) (updatedCount int, err error) {
 	t.Helper()
 
-	err := r.deleteAllEvents(ctx)
+	updatedCount, err = r.deleteAllEvents(ctx)
 	if err != nil {
-		return fmt.Errorf("fail to delete all events: %w", err)
+		return 0, fmt.Errorf("fail to delete all events: %w", err)
 	}
 
-	return nil
+	return updatedCount, nil
 }
 
-func (r *MysqlRepository) deleteAllEvents(ctx context.Context) error {
-	_, err := r.db.ExecContext(ctx, "DELETE FROM events")
+func (r *MysqlRepository) deleteAllEvents(ctx context.Context) (updatedCount int, err error) {
+	result, err := r.db.ExecContext(ctx, "DELETE FROM events")
 	if err != nil {
-		return fmt.Errorf("fail to delete all events: %w", err)
+		return 0, fmt.Errorf("fail to delete all events: %w", err)
 	}
 
-	return nil
+	affectedRows, err := result.RowsAffected()
+	if err != nil {
+		return 0, fmt.Errorf("fail to get affected rows: %w", err)
+	}
+	updatedCount = int(affectedRows)
+
+	return updatedCount, nil
 }
diff --git a/api/repository/mysql/recurring_event_repository.go b/api/repository/mysql/recurring_event_repository.go
new file mode 100644
index 0000000..26dda3a
--- /dev/null
+++ b/api/repository/mysql/recurring_event_repository.go
@@ -0,0 +1,239 @@
+package mysql
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/takuoki/google-calendar-sync/api/domain/constant"
+	"github.com/takuoki/google-calendar-sync/api/domain/entity"
+	"github.com/takuoki/google-calendar-sync/api/domain/valueobject"
+)
+
+func (r *MysqlRepository) ListActiveRecurringEventsWithIDs(ctx context.Context,
+	calendarID valueobject.CalendarID, eventIDs []valueobject.EventID) ([]entity.RecurringEvent, error) {
+
+	if len(eventIDs) == 0 {
+		return []entity.RecurringEvent{}, nil
+	}
+
+	placeholders := make([]string, len(eventIDs))
+	args := make([]interface{}, 0, len(eventIDs)+2)
+	for i, id := range eventIDs {
+		placeholders[i] = "?"
+		args = append(args, id)
+	}
+	args = append([]interface{}{calendarID}, args...)
+	args = append(args, constant.EventStatusCancelled)
+
+	query := "SELECT id, calendar_id, summary, recurrence, start, end, status " +
+		"FROM recurring_events " +
+		"WHERE calendar_id = ? AND id IN (" + strings.Join(placeholders, ",") + ") AND status != ? " +
+		"ORDER BY id"
+
+	rows, err := r.db.QueryContext(ctx, query, args...)
+	if err != nil {
+		return nil, fmt.Errorf("fail to select recurring events: %w", err)
+	}
+	defer func() {
+		if closeErr := rows.Close(); closeErr != nil {
+			r.logger.Errorf(ctx, "fail to close rows: %s", closeErr)
+		}
+	}()
+
+	var recurringEvents []entity.RecurringEvent
+	for rows.Next() {
+		var recurringEvent entity.RecurringEvent
+		err := rows.Scan(&recurringEvent.ID, &recurringEvent.CalendarID, &recurringEvent.Summary,
+			&recurringEvent.Recurrence, &recurringEvent.Start, &recurringEvent.End, &recurringEvent.Status)
+		if err != nil {
+			return nil, fmt.Errorf("fail to scan row: %w", err)
+		}
+
+		recurringEvents = append(recurringEvents, recurringEvent)
+	}
+
+	return recurringEvents, nil
+}
+
+func (r *MysqlRepository) ListActiveRecurringEventsWithAfter(ctx context.Context, calendarID valueobject.CalendarID, after time.Time) (
+	[]entity.RecurringEvent, error) {
+
+	// TODO: recurrence の UNTIL を考慮し、有効期限が切れたイベントも除外したい
+	// 事前に UNTIL をカラムに保存しておく必要がある
+	rows, err := r.db.QueryContext(
+		ctx,
+		"SELECT id, calendar_id, summary, recurrence, start, end, status "+
+			"FROM recurring_events "+
+			"WHERE calendar_id = ? AND status != ? "+
+			"ORDER BY id",
+		calendarID, constant.EventStatusCancelled)
+	if err != nil {
+		return nil, fmt.Errorf("fail to select recurring events: %w", err)
+	}
+	defer func() {
+		if closeErr := rows.Close(); closeErr != nil {
+			r.logger.Errorf(ctx, "fail to close rows: %s", closeErr)
+		}
+	}()
+
+	var recurringEvents []entity.RecurringEvent
+	for rows.Next() {
+		var recurringEvent entity.RecurringEvent
+		err := rows.Scan(&recurringEvent.ID, &recurringEvent.CalendarID, &recurringEvent.Summary,
+			&recurringEvent.Recurrence, &recurringEvent.Start, &recurringEvent.End, &recurringEvent.Status)
+		if err != nil {
+			return nil, fmt.Errorf("fail to scan row: %w", err)
+		}
+
+		recurringEvents = append(recurringEvents, recurringEvent)
+	}
+
+	return recurringEvents, nil
+}
+
+func (tx *mysqlTransaction) SyncRecurringEventAndInstancesWithAfter(ctx context.Context,
+	recurringEvent entity.RecurringEvent, instances []entity.Event, after time.Time) (updatedCount int, err error) {
+
+	updatedCount, err = tx.syncRecurringEvent(ctx, recurringEvent)
+	if err != nil {
+		return 0, fmt.Errorf("fail to sync recurring event: %w", err)
+	}
+
+	cnt, err := tx.SyncEvents(ctx, recurringEvent.CalendarID, instances)
+	if err != nil {
+		return 0, fmt.Errorf("fail to sync events: %w", err)
+	}
+
+	updatedCount += cnt
+
+	// DB に存在するが、引数で渡されなかったイベントは削除
+	eventIDs := make([]valueobject.EventID, 0, len(instances))
+	for _, event := range instances {
+		eventIDs = append(eventIDs, event.ID)
+	}
+
+	cnt, err = tx.cancelEventInstancesWithAfter(
+		ctx, recurringEvent.CalendarID, recurringEvent.ID, eventIDs, after)
+	if err != nil {
+		return 0, fmt.Errorf("fail to cancel recurring event instances: %w", err)
+	}
+
+	updatedCount += cnt
+
+	return updatedCount, nil
+}
+
+func (tx *mysqlTransaction) syncRecurringEvent(ctx context.Context, recurringEvent entity.RecurringEvent) (
+	updatedCount int, err error) {
+
+	// 非定期イベントを定期イベントに更新した場合を考慮し、events テーブルのデータが存在する場合はキャンセルする
+	// すでにキャンセルされている場合も更新される
+	cancelledEvent := entity.NewCancelledEventFromRecurringEvent(recurringEvent)
+	cnt, err := tx.updateEvent(ctx, cancelledEvent)
+	if err != nil {
+		return 0, fmt.Errorf("fail to update event for cancel: %w", err)
+	}
+	updatedCount += cnt
+
+	var dbRecurringEvent entity.RecurringEvent
+	err = tx.tx.QueryRowContext(
+		ctx,
+		"SELECT id, calendar_id, summary, recurrence, start, end, status "+
+			"FROM recurring_events WHERE calendar_id = ? AND id = ?",
+		recurringEvent.CalendarID, recurringEvent.ID,
+	).Scan(&dbRecurringEvent.ID, &dbRecurringEvent.CalendarID, &dbRecurringEvent.Summary,
+		&dbRecurringEvent.Recurrence, &dbRecurringEvent.Start, &dbRecurringEvent.End, &dbRecurringEvent.Status)
+
+	if err != nil && err != sql.ErrNoRows {
+		return 0, fmt.Errorf("fail to select recurring event: %w", err)
+	}
+
+	// DB に存在しない場合は挿入
+	if err == sql.ErrNoRows {
+		if err := tx.createRecurringEvent(ctx, recurringEvent); err != nil {
+			return 0, fmt.Errorf("fail to create recurring event: %w", err)
+		}
+		return updatedCount + 1, nil
+	}
+
+	// DB に存在するが、データが同じ場合はスキップ
+	if recurringEvent.Equals(&dbRecurringEvent) {
+		return updatedCount, nil
+	}
+
+	// DB に存在するが、データが異なる場合は更新
+	if err := tx.updateRecurringEvent(ctx, recurringEvent); err != nil {
+		return 0, fmt.Errorf("fail to update recurring event: %w", err)
+	}
+
+	return updatedCount + 1, nil
+}
+
+func (tx *mysqlTransaction) createRecurringEvent(ctx context.Context, recurringEvent entity.RecurringEvent) error {
+	_, err := tx.tx.ExecContext(
+		ctx,
+		"INSERT INTO recurring_events "+
+			"(calendar_id, id, summary, recurrence, start, end, status) "+
+			"VALUES (?, ?, ?, ?, ?, ?, ?)",
+		recurringEvent.CalendarID, recurringEvent.ID, recurringEvent.Summary,
+		recurringEvent.Recurrence, recurringEvent.Start, recurringEvent.End, recurringEvent.Status)
+	if err != nil {
+		return fmt.Errorf("fail to insert recurring event: %w", err)
+	}
+
+	return nil
+}
+
+func (tx *mysqlTransaction) updateRecurringEvent(ctx context.Context, recurringEvent entity.RecurringEvent) error {
+	_, err := tx.tx.ExecContext(
+		ctx,
+		"UPDATE recurring_events "+
+			"SET summary = ?, recurrence = ?, start = ?, end = ?, status = ? "+
+			"WHERE calendar_id = ? AND id = ?",
+		recurringEvent.Summary, recurringEvent.Recurrence, recurringEvent.Start,
+		recurringEvent.End, recurringEvent.Status, recurringEvent.CalendarID, recurringEvent.ID)
+	if err != nil {
+		return fmt.Errorf("fail to update recurring event: %w", err)
+	}
+
+	return nil
+}
+
+func (r *MysqlRepository) DeleteAllRecurringEventsForMain(ctx context.Context, m *testing.M) (updatedCount int, err error) {
+	updatedCount, err = r.deleteAllRecurringEvents(ctx)
+	if err != nil {
+		return 0, fmt.Errorf("fail to delete all recurring events: %w", err)
+	}
+
+	return updatedCount, nil
+}
+
+func (r *MysqlRepository) DeleteAllRecurringEvents(ctx context.Context, t *testing.T) (updatedCount int, err error) {
+	t.Helper()
+
+	updatedCount, err = r.deleteAllRecurringEvents(ctx)
+	if err != nil {
+		return 0, fmt.Errorf("fail to delete all recurring events: %w", err)
+	}
+
+	return updatedCount, nil
+}
+
+func (r *MysqlRepository) deleteAllRecurringEvents(ctx context.Context) (updatedCount int, err error) {
+	result, err := r.db.ExecContext(ctx, "DELETE FROM recurring_events")
+	if err != nil {
+		return 0, fmt.Errorf("fail to delete all recurring events: %w", err)
+	}
+
+	affectedRows, err := result.RowsAffected()
+	if err != nil {
+		return 0, fmt.Errorf("fail to get affected rows: %w", err)
+	}
+	updatedCount = int(affectedRows)
+
+	return updatedCount, nil
+}
diff --git a/api/repository/mysql/sync_history_repository.go b/api/repository/mysql/sync_history_repository.go
index d857e7d..dc8b6b5 100644
--- a/api/repository/mysql/sync_history_repository.go
+++ b/api/repository/mysql/sync_history_repository.go
@@ -76,31 +76,37 @@ func createSyncHistory(
 	return nil
 }
 
-func (r *MysqlRepository) DeleteAllSyncHistoriesForMain(ctx context.Context, m *testing.M) error {
-	err := r.deleteAllSyncHistories(ctx)
+func (r *MysqlRepository) DeleteAllSyncHistoriesForMain(ctx context.Context, m *testing.M) (updatedCount int, err error) {
+	updatedCount, err = r.deleteAllSyncHistories(ctx)
 	if err != nil {
-		return fmt.Errorf("fail to delete all sync histories: %w", err)
+		return 0, fmt.Errorf("fail to delete all sync histories: %w", err)
 	}
 
-	return nil
+	return updatedCount, nil
 }
 
-func (r *MysqlRepository) DeleteAllSyncHistories(ctx context.Context, t *testing.T) error {
+func (r *MysqlRepository) DeleteAllSyncHistories(ctx context.Context, t *testing.T) (updatedCount int, err error) {
 	t.Helper()
 
-	err := r.deleteAllSyncHistories(ctx)
+	updatedCount, err = r.deleteAllSyncHistories(ctx)
 	if err != nil {
-		return fmt.Errorf("fail to delete all sync histories: %w", err)
+		return 0, fmt.Errorf("fail to delete all sync histories: %w", err)
 	}
 
-	return nil
+	return updatedCount, nil
 }
 
-func (r *MysqlRepository) deleteAllSyncHistories(ctx context.Context) error {
-	_, err := r.db.ExecContext(ctx, "DELETE FROM sync_histories")
+func (r *MysqlRepository) deleteAllSyncHistories(ctx context.Context) (updatedCount int, err error) {
+	result, err := r.db.ExecContext(ctx, "DELETE FROM sync_histories")
 	if err != nil {
-		return fmt.Errorf("fail to delete all sync histories: %w", err)
+		return 0, fmt.Errorf("fail to delete all sync histories: %w", err)
 	}
 
-	return nil
+	affectedRows, err := result.RowsAffected()
+	if err != nil {
+		return 0, fmt.Errorf("fail to get affected rows: %w", err)
+	}
+	updatedCount = int(affectedRows)
+
+	return updatedCount, nil
 }
diff --git a/api/repository/repository.go b/api/repository/repository.go
index 04707c8..4469b34 100644
--- a/api/repository/repository.go
+++ b/api/repository/repository.go
@@ -11,10 +11,13 @@ import (
 type GoogleCalendarRepository interface {
 	// events
 	ListEventsWithAfter(ctx context.Context, calendarID valueobject.CalendarID, after time.Time) (
-		events []entity.Event, nextSyncToken string, err error)
+		events []entity.Event, recurringEvents []entity.RecurringEvent, nextSyncToken string, err error)
 
 	ListEventsWithSyncToken(ctx context.Context, calendarID valueobject.CalendarID, syncToken string) (
-		events []entity.Event, nextSyncToken string, err error)
+		events []entity.Event, recurringEvents []entity.RecurringEvent, nextSyncToken string, err error)
+
+	ListEventInstancesBetween(ctx context.Context, calendarID valueobject.CalendarID, eventID valueobject.EventID, from, to time.Time) (
+		[]entity.Event, error)
 
 	Watch(ctx context.Context, calendarID valueobject.CalendarID) (*entity.Channel, error)
 
@@ -30,6 +33,10 @@ type DatabaseRepository interface {
 	ListCalendars(ctx context.Context) ([]entity.Calendar, error)
 	GetRefreshToken(ctx context.Context, calendarID valueobject.CalendarID) (string, error)
 
+	// recurring_events
+	ListActiveRecurringEventsWithIDs(ctx context.Context, calendarID valueobject.CalendarID, eventIDs []valueobject.EventID) ([]entity.RecurringEvent, error)
+	ListActiveRecurringEventsWithAfter(ctx context.Context, calendarID valueobject.CalendarID, after time.Time) ([]entity.RecurringEvent, error)
+
 	// sync_histories
 	GetLatestSyncToken(ctx context.Context, calendarID valueobject.CalendarID) (syncToken string, err error)
 }
@@ -39,8 +46,12 @@ type DatabaseTransaction interface {
 	LockCalendar(ctx context.Context, calendarID valueobject.CalendarID) error
 	CreateCalendar(ctx context.Context, calendar entity.Calendar) error
 
+	// recurring_events
+	SyncRecurringEventAndInstancesWithAfter(ctx context.Context, recurringEvent entity.RecurringEvent, instances []entity.Event, after time.Time) (
+		updatedCount int, err error)
+
 	// events
-	SyncEvents(ctx context.Context, events []entity.Event) (updatedCount int, err error)
+	SyncEvents(ctx context.Context, calendarID valueobject.CalendarID, events []entity.Event) (updatedCount int, err error)
 
 	// channel_histories
 	ListActiveChannelHistoriesWithLock(ctx context.Context, calendarID valueobject.CalendarID) ([]entity.Channel, error)
diff --git a/api/usecase/mock_test.go b/api/usecase/mock_test.go
index 62a7830..cc90057 100644
--- a/api/usecase/mock_test.go
+++ b/api/usecase/mock_test.go
@@ -9,24 +9,32 @@ import (
 )
 
 type GoogleCalendarRepositoryMock struct {
-	ListEventsWithAfterFunc     func(ctx context.Context, calendarID valueobject.CalendarID, after time.Time) ([]entity.Event, string, error)
-	ListEventsWithSyncTokenFunc func(ctx context.Context, calendarID valueobject.CalendarID, syncToken string) ([]entity.Event, string, error)
-	WatchFunc                   func(ctx context.Context, calendarID valueobject.CalendarID) (*entity.Channel, error)
-	StopWatchFunc               func(ctx context.Context, channel entity.Channel) error
+	ListEventsWithAfterFunc       func(ctx context.Context, calendarID valueobject.CalendarID, after time.Time) ([]entity.Event, []entity.RecurringEvent, string, error)
+	ListEventsWithSyncTokenFunc   func(ctx context.Context, calendarID valueobject.CalendarID, syncToken string) ([]entity.Event, []entity.RecurringEvent, string, error)
+	ListEventInstancesBetweenFunc func(ctx context.Context, calendarID valueobject.CalendarID, eventID valueobject.EventID, from, to time.Time) ([]entity.Event, error)
+	WatchFunc                     func(ctx context.Context, calendarID valueobject.CalendarID) (*entity.Channel, error)
+	StopWatchFunc                 func(ctx context.Context, channel entity.Channel) error
 }
 
-func (m *GoogleCalendarRepositoryMock) ListEventsWithAfter(ctx context.Context, calendarID valueobject.CalendarID, after time.Time) ([]entity.Event, string, error) {
+func (m *GoogleCalendarRepositoryMock) ListEventsWithAfter(ctx context.Context, calendarID valueobject.CalendarID, after time.Time) ([]entity.Event, []entity.RecurringEvent, string, error) {
 	if m.ListEventsWithAfterFunc != nil {
 		return m.ListEventsWithAfterFunc(ctx, calendarID, after)
 	}
-	return nil, "", nil
+	return nil, nil, "", nil
 }
 
-func (m *GoogleCalendarRepositoryMock) ListEventsWithSyncToken(ctx context.Context, calendarID valueobject.CalendarID, syncToken string) ([]entity.Event, string, error) {
+func (m *GoogleCalendarRepositoryMock) ListEventsWithSyncToken(ctx context.Context, calendarID valueobject.CalendarID, syncToken string) ([]entity.Event, []entity.RecurringEvent, string, error) {
 	if m.ListEventsWithSyncTokenFunc != nil {
 		return m.ListEventsWithSyncTokenFunc(ctx, calendarID, syncToken)
 	}
-	return nil, "", nil
+	return nil, nil, "", nil
+}
+
+func (m *GoogleCalendarRepositoryMock) ListEventInstancesBetween(ctx context.Context, calendarID valueobject.CalendarID, eventID valueobject.EventID, from, to time.Time) ([]entity.Event, error) {
+	if m.ListEventInstancesBetweenFunc != nil {
+		return m.ListEventInstancesBetweenFunc(ctx, calendarID, eventID, from, to)
+	}
+	return nil, nil
 }
 
 func (m *GoogleCalendarRepositoryMock) Watch(ctx context.Context, calendarID valueobject.CalendarID) (*entity.Channel, error) {
diff --git a/api/usecase/sync_usecase.go b/api/usecase/sync_usecase.go
index c5ffe67..f20b091 100644
--- a/api/usecase/sync_usecase.go
+++ b/api/usecase/sync_usecase.go
@@ -7,12 +7,19 @@ import (
 
 	"github.com/takuoki/golib/applog"
 	"github.com/takuoki/google-calendar-sync/api/domain"
+	"github.com/takuoki/google-calendar-sync/api/domain/constant"
 	"github.com/takuoki/google-calendar-sync/api/domain/entity"
 	"github.com/takuoki/google-calendar-sync/api/domain/service"
 	"github.com/takuoki/google-calendar-sync/api/domain/valueobject"
 	"github.com/takuoki/google-calendar-sync/api/repository"
 )
 
+const (
+	syncEventFrom         = -1 * 7 * 24 * time.Hour // 1 週間前
+	syncEventInstanceFrom = syncEventFrom
+	syncEventInstanceTo   = 365 * 24 * time.Hour // 1 年後
+)
+
 type SyncUsecase interface {
 	Sync(ctx context.Context, calendarID valueobject.CalendarID) error
 }
@@ -44,64 +51,235 @@ func (u *syncUsecase) Sync(ctx context.Context, calendarID valueobject.CalendarI
 		return fmt.Errorf("fail to get calendar: %w", err)
 	}
 
+	events, recurringEvents, nextSyncToken, err := u.listEventsFromGoogleCalendar(ctx, calendarID)
+	if err != nil {
+		return fmt.Errorf("fail to list events from Google Calendar: %w", err)
+	}
+
+	syncTime := u.clockService.Now()
+
+	// 定期イベントの削除や Recurrence の削除を考慮し、 events と recurringEvents を更新する
+	events, recurringEvents, err = u.moveOrCopyCancelledRecurringEvents(ctx, events, recurringEvents)
+	if err != nil {
+		return fmt.Errorf("fail to move cancelled recurring events: %w", err)
+	}
+
+	shouldSaveRecurringEvents, eventInstanceMap, err := u.listEventInstancesFromGoogleCalendar(
+		ctx, calendarID, recurringEvents, syncTime)
+	if err != nil {
+		return fmt.Errorf("fail to list event instances from Google Calendar: %w", err)
+	}
+
+	err = u.databaseRepo.RunTransaction(ctx, func(ctx context.Context, tx repository.DatabaseTransaction) error {
+
+		u.logger.Trace(ctx, "start transaction")
+
+		if err := tx.LockCalendar(ctx, calendarID); err != nil {
+			return fmt.Errorf("fail to lock calendar: %w", err)
+		}
+
+		updatedEventCount := 0
+
+		// events には定期イベントの個別イベントが含まれる可能性があるため、先に定期イベントを登録する
+		for _, recurringEvent := range shouldSaveRecurringEvents {
+			u.logger.Tracef(ctx, "sync recurring event: eventID=%q", recurringEvent.ID)
+
+			instances := eventInstanceMap[recurringEvent.ID]
+			cnt, err := tx.SyncRecurringEventAndInstancesWithAfter(
+				ctx, recurringEvent, instances, syncTime.Add(syncEventInstanceFrom))
+			if err != nil {
+				return fmt.Errorf("fail to sync recurring events: %w", err)
+			}
+
+			updatedEventCount += cnt
+		}
+
+		u.logger.Trace(ctx, "sync events")
+		cnt, err := tx.SyncEvents(ctx, calendarID, events)
+		if err != nil {
+			return fmt.Errorf("fail to sync events: %w", err)
+		}
+
+		updatedEventCount += cnt
+
+		if err := tx.CreateSyncHistory(ctx, calendarID, syncTime, nextSyncToken, updatedEventCount); err != nil {
+			return fmt.Errorf("fail to create sync history: %w", err)
+		}
+
+		return nil
+	})
+
+	if err != nil {
+		return fmt.Errorf("fail to run transaction: %w", err)
+	}
+
+	return nil
+}
+
+// listEventsFromGoogleCalendar retrieves events and recurring events from a Google Calendar.
+//
+// It first attempts to fetch events using the latest sync token stored in the database.
+// If the sync token is outdated or does not exist, it falls back to fetching all events from the calendar.
+//
+// Recurring event and non-recurring event are determined by the presence or absence
+// of the latest recurring setting (`recurrence`).
+// Note that if a recurring event is changed to a non-recurring event,
+// the recurring event before the change is not included (and vice versa).
+//
+// Returns:
+// - events: A slice of non-recurring events retrieved from the calendar.
+// - recurringEvents: A slice of recurring events retrieved from the calendar.
+// - nextSyncToken: A string representing the next sync token to be used for subsequent sync operations.
+// - error: An error object if the operation fails, or nil if successful.
+func (u *syncUsecase) listEventsFromGoogleCalendar(ctx context.Context, calendarID valueobject.CalendarID) (
+	[]entity.Event, []entity.RecurringEvent, string, error) {
 	syncToken, err := u.databaseRepo.GetLatestSyncToken(ctx, calendarID)
 	if err != nil {
-		return fmt.Errorf("fail to get latest sync token: %w", err)
+		return nil, nil, "", fmt.Errorf("fail to get latest sync token: %w", err)
 	}
 
 	var events []entity.Event
+	var recurringEvents []entity.RecurringEvent
 	var nextSyncToken string
 	if syncToken != "" {
-		events, nextSyncToken, err = u.googleCalenderRepo.ListEventsWithSyncToken(ctx, calendarID, syncToken)
+		events, recurringEvents, nextSyncToken, err = u.googleCalenderRepo.ListEventsWithSyncToken(ctx, calendarID, syncToken)
 		if err != nil {
 			if err == domain.SyncTokenIsOldError {
 				// syncToken が古い場合は、全件取得して更新する
 				u.logger.Info(ctx, "sync token is old, sync all events")
-				events, nextSyncToken, err = u.syncAll(ctx, calendarID)
+				events, recurringEvents, nextSyncToken, err = u.listAllEventsFromGoogleCalendar(ctx, calendarID)
 				if err != nil {
-					return fmt.Errorf("fail to sync all events (sync token is old): %w", err)
+					return nil, nil, "", fmt.Errorf("fail to sync all events (sync token is old): %w", err)
 				}
 			} else {
-				return fmt.Errorf("fail to list events: %w", err)
+				return nil, nil, "", fmt.Errorf("fail to list events: %w", err)
 			}
 		}
 	} else {
 		u.logger.Info(ctx, "sync all events")
-		events, nextSyncToken, err = u.syncAll(ctx, calendarID)
+		events, recurringEvents, nextSyncToken, err = u.listAllEventsFromGoogleCalendar(ctx, calendarID)
 		if err != nil {
-			return fmt.Errorf("fail to sync all events (sync token doesn't exist): %w", err)
+			return nil, nil, "", fmt.Errorf("fail to sync all events (sync token doesn't exist): %w", err)
 		}
 	}
 
-	syncTime := u.clockService.Now()
+	return events, recurringEvents, nextSyncToken, nil
+}
 
-	err = u.databaseRepo.RunTransaction(ctx, func(ctx context.Context, tx repository.DatabaseTransaction) error {
-		updatedEventCount, err := tx.SyncEvents(ctx, events)
-		if err != nil {
-			return fmt.Errorf("fail to sync events: %w", err)
+func (u *syncUsecase) listAllEventsFromGoogleCalendar(ctx context.Context, calendarID valueobject.CalendarID) (
+	[]entity.Event, []entity.RecurringEvent, string, error) {
+	after := u.clockService.Today().Add(syncEventFrom)
+
+	events, recurringEvents, nextSyncToken, err := u.googleCalenderRepo.ListEventsWithAfter(ctx, calendarID, after)
+	if err != nil {
+		return nil, nil, "", fmt.Errorf("fail to list events: %w", err)
+	}
+
+	return events, recurringEvents, nextSyncToken, nil
+}
+
+func (u *syncUsecase) moveOrCopyCancelledRecurringEvents(ctx context.Context,
+	events []entity.Event, recurringEvents []entity.RecurringEvent) (
+	[]entity.Event, []entity.RecurringEvent, error) {
+
+	if len(events) == 0 {
+		return events, recurringEvents, nil
+	}
+
+	eventIDs := make([]valueobject.EventID, 0, len(events))
+	for _, event := range events {
+		eventIDs = append(eventIDs, event.ID)
+	}
+
+	// 定期イベントテーブルに有効で存在するものを取得
+	// すでにキャンセルされている場合は、子イベントもキャンセルになっているはずなので抽出対象外
+	dbRecurringEvents, err := u.databaseRepo.ListActiveRecurringEventsWithIDs(ctx, events[0].CalendarID, eventIDs)
+	if err != nil {
+		return nil, nil, fmt.Errorf("fail to list recurring events: %w", err)
+	}
+	recurringEventMap, err := u.convertToRecurringEventMap(dbRecurringEvents)
+	if err != nil {
+		return nil, nil, fmt.Errorf("fail to convert recurring events: %w", err)
+	}
+
+	resEvents := make([]entity.Event, 0, len(events))
+	resRecurringEvents := recurringEvents
+	for _, event := range events {
+		if _, ok := recurringEventMap[event.ID]; !ok {
+			// DB に存在しない定期イベントは通常のイベントとして扱う
+			resEvents = append(resEvents, event)
+			continue
 		}
 
-		if err := tx.CreateSyncHistory(ctx, calendarID, syncTime, nextSyncToken, updatedEventCount); err != nil {
-			return fmt.Errorf("fail to create sync history: %w", err)
+		cancelledRecurringEvent := entity.NewCancelledRecurringEventFromEvent(event)
+		if event.Status == constant.EventStatusCancelled {
+			// イベントのステータスがキャンセルの場合、通常のイベントとしては扱わず、定期イベントをキャンセル扱いにする
+			resRecurringEvents = append(resRecurringEvents, cancelledRecurringEvent)
+		} else {
+			// イベントのステータスがキャンセル以外の場合、通常のイベントとして扱いつつ、定期イベントをキャンセル扱いにする
+			resEvents = append(resEvents, event)
+			resRecurringEvents = append(resRecurringEvents, cancelledRecurringEvent)
 		}
+	}
 
-		return nil
-	})
+	return resEvents, resRecurringEvents, nil
+}
+
+func (u *syncUsecase) listEventInstancesFromGoogleCalendar(ctx context.Context,
+	calendarID valueobject.CalendarID, recurringEvents []entity.RecurringEvent, syncTime time.Time) (
+	[]entity.RecurringEvent, map[valueobject.EventID][]entity.Event, error) {
 
+	if len(recurringEvents) == 0 {
+		return nil, nil, nil
+	}
+
+	shouldSaveRecurringEvents := make([]entity.RecurringEvent, 0, len(recurringEvents))
+	eventInstanceMap := map[valueobject.EventID][]entity.Event{}
+
+	// ここでは終了日が到達していない定期イベントのみを取得する
+	// 終了日が到達した定期イベントの終了日が延期された場合はここでは取得されず、
+	// 新規定期イベントと同様の挙動となり、後続の SyncRecurringEventAndInstancesWithAfter が呼ばれる
+	// （登録時に再度、存在チェックを行なっているため、新規登録ではなく更新処理となる）
+	dbRecurringEvents, err := u.databaseRepo.ListActiveRecurringEventsWithAfter(ctx, calendarID, syncTime.Add(syncEventFrom))
 	if err != nil {
-		return fmt.Errorf("fail to run transaction: %w", err)
+		return nil, nil, fmt.Errorf("fail to list recurring events: %w", err)
+	}
+	recurringEventMap, err := u.convertToRecurringEventMap(dbRecurringEvents)
+	if err != nil {
+		return nil, nil, fmt.Errorf("fail to convert recurring events: %w", err)
 	}
 
-	return nil
+	for _, recurringEvent := range recurringEvents {
+		dbRecurringEvent, ok := recurringEventMap[recurringEvent.ID]
+		if ok && recurringEvent.Equals(&dbRecurringEvent) {
+			// 既存の定期イベントと同じ場合はスキップ
+			continue
+		}
+
+		var instances []entity.Event
+		if recurringEvent.Status != constant.EventStatusCancelled {
+			instances, err = u.googleCalenderRepo.ListEventInstancesBetween(
+				ctx, calendarID, recurringEvent.ID,
+				syncTime.Add(syncEventInstanceFrom), syncTime.Add(syncEventInstanceTo))
+			if err != nil {
+				return nil, nil, fmt.Errorf("fail to list event instances: %w", err)
+			}
+		}
+
+		shouldSaveRecurringEvents = append(shouldSaveRecurringEvents, recurringEvent)
+		eventInstanceMap[recurringEvent.ID] = instances
+	}
+
+	return shouldSaveRecurringEvents, eventInstanceMap, nil
 }
 
-func (u *syncUsecase) syncAll(ctx context.Context, calendarID valueobject.CalendarID) ([]entity.Event, string, error) {
-	oneWeekAgo := u.clockService.Today().Add(7 * -24 * time.Hour)
+func (u *syncUsecase) convertToRecurringEventMap(recurringEvents []entity.RecurringEvent) (
+	map[valueobject.EventID]entity.RecurringEvent, error) {
 
-	events, nextSyncToken, err := u.googleCalenderRepo.ListEventsWithAfter(ctx, calendarID, oneWeekAgo)
-	if err != nil {
-		return nil, "", fmt.Errorf("fail to list events: %w", err)
+	recurringEventMap := map[valueobject.EventID]entity.RecurringEvent{}
+	for _, recurringEvent := range recurringEvents {
+		recurringEventMap[recurringEvent.ID] = recurringEvent
 	}
 
-	return events, nextSyncToken, nil
+	return recurringEventMap, nil
 }
diff --git a/api/usecase/sync_usecase_test.go b/api/usecase/sync_usecase_test.go
index 0f087d6..b9611b2 100644
--- a/api/usecase/sync_usecase_test.go
+++ b/api/usecase/sync_usecase_test.go
@@ -63,8 +63,8 @@ func TestSyncUsecase_Sync_Success_AllEvents(t *testing.T) {
 
 	mockRepo := &GoogleCalendarRepositoryMock{
 		ListEventsWithAfterFunc: func(ctx context.Context,
-			calendarID valueobject.CalendarID, after time.Time) ([]entity.Event, string, error) {
-			return []entity.Event{event1, event2}, "new-sync-token", nil
+			calendarID valueobject.CalendarID, after time.Time) ([]entity.Event, []entity.RecurringEvent, string, error) {
+			return []entity.Event{event1, event2}, []entity.RecurringEvent{}, "new-sync-token", nil
 		},
 	}
 
@@ -129,9 +129,9 @@ func TestSyncUsecase_Sync_Success_WithSyncToken(t *testing.T) {
 
 	mockRepo := &GoogleCalendarRepositoryMock{
 		ListEventsWithSyncTokenFunc: func(ctx context.Context,
-			calendarID valueobject.CalendarID, syncToken string) ([]entity.Event, string, error) {
+			calendarID valueobject.CalendarID, syncToken string) ([]entity.Event, []entity.RecurringEvent, string, error) {
 			assert.Equal(t, "sync-token", syncToken)
-			return []entity.Event{event1, event2}, "new-sync-token", nil
+			return []entity.Event{event1, event2}, []entity.RecurringEvent{}, "new-sync-token", nil
 		},
 	}
 
@@ -207,12 +207,12 @@ func TestSyncUsecase_Sync_Success_OldSyncToken(t *testing.T) {
 
 	mockRepo := &GoogleCalendarRepositoryMock{
 		ListEventsWithSyncTokenFunc: func(ctx context.Context,
-			calendarID valueobject.CalendarID, syncToken string) ([]entity.Event, string, error) {
-			return nil, "", domain.SyncTokenIsOldError
+			calendarID valueobject.CalendarID, syncToken string) ([]entity.Event, []entity.RecurringEvent, string, error) {
+			return nil, nil, "", domain.SyncTokenIsOldError
 		},
 		ListEventsWithAfterFunc: func(ctx context.Context,
-			calendarID valueobject.CalendarID, after time.Time) ([]entity.Event, string, error) {
-			return []entity.Event{event1, event2}, "new-sync-token", nil
+			calendarID valueobject.CalendarID, after time.Time) ([]entity.Event, []entity.RecurringEvent, string, error) {
+			return []entity.Event{event1, event2}, []entity.RecurringEvent{}, "new-sync-token", nil
 		},
 	}
 
diff --git a/api/usecase/usecate_test.go b/api/usecase/usecate_test.go
index 1de09c6..1f39577 100644
--- a/api/usecase/usecate_test.go
+++ b/api/usecase/usecate_test.go
@@ -76,16 +76,19 @@ func waitForDatabaseReady(ctx context.Context, db *sql.DB) error {
 }
 
 func cleanupForMain(ctx context.Context, m *testing.M) {
-	if err := mysqlRepo.DeleteAllSyncHistoriesForMain(ctx, m); err != nil {
+	if _, err := mysqlRepo.DeleteAllSyncHistoriesForMain(ctx, m); err != nil {
 		panic("fail to delete all sync histories: " + err.Error())
 	}
-	if err := mysqlRepo.DeleteAllChannelHistoriesForMain(ctx, m); err != nil {
+	if _, err := mysqlRepo.DeleteAllChannelHistoriesForMain(ctx, m); err != nil {
 		panic("fail to delete all channel histories: " + err.Error())
 	}
-	if err := mysqlRepo.DeleteAllEventsForMain(ctx, m); err != nil {
+	if _, err := mysqlRepo.DeleteAllEventsForMain(ctx, m); err != nil {
 		panic("fail to delete all events: " + err.Error())
 	}
-	if err := mysqlRepo.DeleteAllCalendarsForMain(ctx, m); err != nil {
+	if _, err := mysqlRepo.DeleteAllRecurringEventsForMain(ctx, m); err != nil {
+		panic("fail to delete all recurring events: " + err.Error())
+	}
+	if _, err := mysqlRepo.DeleteAllCalendarsForMain(ctx, m); err != nil {
 		panic("fail to delete all calendars: " + err.Error())
 	}
 }
@@ -93,16 +96,19 @@ func cleanupForMain(ctx context.Context, m *testing.M) {
 func cleanup(ctx context.Context, t *testing.T) {
 	t.Helper()
 
-	if err := mysqlRepo.DeleteAllSyncHistories(ctx, t); err != nil {
+	if _, err := mysqlRepo.DeleteAllSyncHistories(ctx, t); err != nil {
 		panic("fail to delete all sync histories: " + err.Error())
 	}
-	if err := mysqlRepo.DeleteAllChannelHistories(ctx, t); err != nil {
+	if _, err := mysqlRepo.DeleteAllChannelHistories(ctx, t); err != nil {
 		panic("fail to delete all channel histories: " + err.Error())
 	}
-	if err := mysqlRepo.DeleteAllEvents(ctx, t); err != nil {
+	if _, err := mysqlRepo.DeleteAllEvents(ctx, t); err != nil {
 		panic("fail to delete all events: " + err.Error())
 	}
-	if err := mysqlRepo.DeleteAllCalendars(ctx, t); err != nil {
+	if _, err := mysqlRepo.DeleteAllRecurringEvents(ctx, t); err != nil {
+		panic("fail to delete all recurring events: " + err.Error())
+	}
+	if _, err := mysqlRepo.DeleteAllCalendars(ctx, t); err != nil {
 		panic("fail to delete all calendars: " + err.Error())
 	}
 }
@@ -124,10 +130,13 @@ func assertEqualTime(t *testing.T, expected, actual *time.Time) bool {
 func assertEqualEvent(t *testing.T, expected, actual entity.Event) bool {
 	t.Helper()
 
+	if !assert.Equal(t, expected.CalendarID, actual.CalendarID) {
+		return false
+	}
 	if !assert.Equal(t, expected.ID, actual.ID) {
 		return false
 	}
-	if !assert.Equal(t, expected.CalendarID, actual.CalendarID) {
+	if !assert.Equal(t, expected.RecurringEventID, actual.RecurringEventID) {
 		return false
 	}
 	if !assert.Equal(t, expected.Summary, actual.Summary) {
diff --git a/db/init.sql b/db/init.sql
index cc96531..0466283 100644
--- a/db/init.sql
+++ b/db/init.sql
@@ -6,10 +6,11 @@ CREATE TABLE IF NOT EXISTS calendars (
     updated_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
 );
 
-CREATE TABLE IF NOT EXISTS events (
-    id VARCHAR(255) NOT NULL,
+CREATE TABLE IF NOT EXISTS recurring_events (
     calendar_id VARCHAR(255) NOT NULL,
+    id VARCHAR(255) NOT NULL,
     summary VARCHAR(255) NOT NULL,
+    recurrence VARCHAR(255) NOT NULL,
     start TIMESTAMP,
     end TIMESTAMP,
     status VARCHAR(255) NOT NULL,
@@ -19,6 +20,21 @@ CREATE TABLE IF NOT EXISTS events (
     FOREIGN KEY (calendar_id) REFERENCES calendars(id)
 );
 
+CREATE TABLE IF NOT EXISTS events (
+    calendar_id VARCHAR(255) NOT NULL,
+    id VARCHAR(255) NOT NULL,
+    recurring_event_id VARCHAR(255),
+    summary VARCHAR(255) NOT NULL,
+    start TIMESTAMP,
+    end TIMESTAMP,
+    status VARCHAR(255) NOT NULL,
+    created_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3),
+    updated_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+    PRIMARY KEY (calendar_id, id),
+    FOREIGN KEY (calendar_id) REFERENCES calendars(id),
+    FOREIGN KEY (calendar_id, recurring_event_id) REFERENCES recurring_events(calendar_id, id)
+);
+
 CREATE TABLE IF NOT EXISTS channel_histories (
     calendar_id VARCHAR(255),
     start_time TIMESTAMP(3) NOT NULL,


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR adds a new method, ListEventInstances, to the GoogleCalendarRepository interface and provides its implementation across both the mock and the production repository. The key changes include:
- Adding ListEventInstances to the GoogleCalendarRepositoryMock struct and its implementation.
- Updating the GoogleCalendarRepository interface in the repository package.
- Implementing ListEventInstances in the googlecalendar package with appropriate handling via an eventsInstancesCallWrapper.

### Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

| File                                                   | Description                                                      |
| ------------------------------------------------------ | ---------------------------------------------------------------- |
| api/usecase/mock_test.go                               | Added ListEventInstancesFunc and a corresponding ListEventInstances method for mocks. |
| api/repository/repository.go                           | Updated the repository interface to include ListEventInstances.  |
| api/repository/googlecalendar/list_events.go           | No direct changes for ListEventInstances; existing listEvents logic is reused. |
| api/repository/googlecalendar/event_repository.go      | Added implementations for ListEventInstances in both OAuth and non-OAuth flows. |


<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**api/usecase/mock_test.go:14**
* [nitpick] The naming 'ListEventInstancesFunc' is consistent with similar function pointers; consider adding a brief documentation comment to clarify its intended behavior.
```
ListEventInstancesFunc      func(ctx context.Context, calendarID valueobject.CalendarID, eventID valueobject.EventID) ([]entity.Event, error)
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-05-11T06:16:44Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

Enable syncing of recurring Google Calendar events by extending the use case and repository layers to fetch, process, and store both one-off and recurring event data.

- Introduce fetching of recurring events and their instances, and handle cancelled recurrences.
- Update repository interfaces, mocks, and MySQL implementations to return affected row counts and support recurring-event flows.
- Add new domain helpers (`NewEventID`, `comparePointer`) and expand `Event`/`RecurringEvent` equality logic and tests.

### Reviewed Changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File                                    | Description                                                     |
|-----------------------------------------|-----------------------------------------------------------------|
| api/usecase/sync_usecase.go             | Add `listEventsFromGoogleCalendar`, handling for recurring events and instances |
| api/usecase/sync_usecase_test.go        | Update tests/mocks to return `[]entity.RecurringEvent`          |
| api/usecase/mock_test.go                | Extend mock signatures for recurring-events and instance calls  |
| api/repository/repository.go            | Extend interface methods to include recurring events and instances |
| api/repository/mysql/*                  | Change delete methods to return update counts; add recurring event repository and instance sync in transactions |
| api/repository/googlecalendar/*         | Wrap Google API calls to return recurring events and instances |
| api/domain/valueobject/id.go            | Add `NewEventID` and generic `pointer` helper                   |
| api/domain/entity/*                     | Update `Event`/`RecurringEvent` structs and `Equals` methods; add `comparePointer` utility; expand tests |
| api/domain/constant/constant.go         | Introduce `EventStatusCancelled` constant                       |
</details>



<details>
<summary>Comments suppressed due to low confidence (3)</summary>

**api/domain/entity/recurring_event_test.go:95**
* [nitpick] The test case label `different RecurringEventID` mismatches its body (it changes `CalendarID` again). Rename or adjust the body to actually vary the recurrence ID or correct the label.
```
"different RecurringEventID": {
```
**api/domain/entity/event_test.go:91**
* [nitpick] The `different RecurringEventID` test case also changes `CalendarID`, which conflates two differences. Limit the change to only the RecurringEventID or correct the test name.
```
"different RecurringEventID": {
```
**api/usecase/sync_usecase.go:164**
* This new method that handles cancelled recurring events isn’t covered by existing tests. Add a unit test to validate that cancelled and non-cancelled recurrences are correctly partitioned.
```
func (u *syncUsecase) moveOrCopyCancelledRecurringEvents(ctx context.Context, events []entity.Event, recurringEvents []entity.RecurringEvent) ([]entity.Event, []entity.RecurringEvent, error) {
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-05-18T08:40:25Z

Review comments:
diff_hunk: @@ -49,74 +55,50 @@ func (r *googleCalendarWithOauthRepository) ListEventsWithSyncToken(
 		return nil, "", fmt.Errorf("fail to get calendar service: %w", err)
 	}
 
-	call := service.Events.List(string(calendarID)).Context(ctx).SyncToken(syncToken)
-	return listEvents(ctx, call, calendarID, r.clockService, r.logger)
+	return listEventsWithSyncToken(ctx, service, r.clockService, r.logger, calendarID, syncToken)
 }
 
-// TODO: EventsListCall だけでなく、EventsInstancesCall も受け取れるようにする
-func listEvents(ctx context.Context, baseCall *calendar.EventsListCall,
-	calendarID valueobject.CalendarID, clockService service.Clock, logger applog.Logger) ([]entity.Event, string, error) {
-
-	pageToken := ""
-	syncToken := ""
-	res := []entity.Event{}
-	for syncToken == "" { // 最後のページまで取得すると必ず値が入る
-		if err := ctx.Err(); err != nil {
-			return nil, "", fmt.Errorf("context error: %w", err)
-		}
-
-		var call *calendar.EventsListCall
-		if pageToken == "" {
-			call = baseCall
-		} else {
-			call = baseCall.PageToken(pageToken)
-		}
-
-		events, err := call.Do()
-		if err != nil {
-			// WARNING: syncToken が古い場合については動作確認未実施
-			// see: https://pkg.go.dev/google.golang.org/api/calendar/v3#EventsListCall.SyncToken
-			if gErr, ok := err.(*googleapi.Error); ok && gErr.Code == 410 {
-				return nil, "", domain.SyncTokenIsOldError
-			}
-			return nil, "", fmt.Errorf("fail to list events: %w", err)
-		}
-
-		for _, item := range events.Items {
-
-			// TODO: この Debug ログは最終的には削除する（ログの量が多いため）
-			itemJSON, err := json.Marshal(item)
-			if err != nil {
-				return nil, "", fmt.Errorf("fail to marshal event item to JSON: %w", err)
-			}
-			logger.Debugf(ctx, "event detail: %s", string(itemJSON))
-
-			start, err := convertDateTime(item.Start, events.TimeZone)
-			if err != nil {
-				return nil, "", fmt.Errorf("fail to convert start datetime: %w", err)
-			}
-			end, err := convertDateTime(item.End, events.TimeZone)
-			if err != nil {
-				return nil, "", fmt.Errorf("fail to convert end datetime: %w", err)
-			}
-
-			res = append(res, entity.Event{
-				ID:         valueobject.EventID(item.Id),
-				CalendarID: calendarID,
-				Summary:    item.Summary,
-				Start:      start,
-				End:        end,
-				Status:     item.Status,
-			})
-		}
-
-		pageToken = events.NextPageToken
-		syncToken = events.NextSyncToken
-
-		logger.Debugf(ctx, "list events: pageToken=%q, syncToken=%q", pageToken, syncToken)
+func listEventsWithSyncToken(ctx context.Context, service *calendar.Service, clockService service.Clock, logger applog.Logger,
+	calendarID valueobject.CalendarID, syncToken string) ([]entity.Event, string, error) {
+
+	call := &eventsListCallWrapper{
+		call: service.Events.List(string(calendarID)).Context(ctx).
+			SyncToken(syncToken),
 	}
 
-	return res, syncToken, nil
+	return listEvents(ctx, clockService, logger, call, calendarID)
+}
+
+func (r *googleCalendarRepository) ListEventInstances(
+	ctx context.Context, calendarID valueobject.CalendarID, eventID valueobject.EventID) ([]entity.Event, error) {
+	return listEventInstances(ctx, r.service, r.clockService, r.logger, calendarID, eventID)
+}
+
+func (r *googleCalendarWithOauthRepository) ListEventInstances(
+	ctx context.Context, calendarID valueobject.CalendarID, eventID valueobject.EventID) ([]entity.Event, error) {
+
+	service, err := r.getCalendarService(ctx, calendarID)
+	if err != nil {
+		return nil, fmt.Errorf("fail to get calendar service: %w", err)
path: api/repository/googlecalendar/event_repository.go
user: login: Copilot
body: The error message 'fail to get calendar service: %w' used in ListEventInstances could be standardized with other similar methods for improved consistency in error reporting.
```suggestion
		return nil, fmt.Errorf("failed to get calendar service: %w", err)
```
created_at: 2025-05-11T06:16:43Z
author_association: NONE
diff_hunk: @@ -44,64 +51,215 @@ func (u *syncUsecase) Sync(ctx context.Context, calendarID valueobject.CalendarI
 		return fmt.Errorf("fail to get calendar: %w", err)
 	}
 
+	events, recurringEvents, nextSyncToken, err := u.listEventsFromGoogleCalendar(ctx, calendarID)
+	if err != nil {
+		return fmt.Errorf("fail to list events from Google Calendar: %w", err)
+	}
+
+	syncTime := u.clockService.Now()
+
+	// 定期イベントの削除や Recurrence の削除を考慮し、 events と recurringEvents を更新する
+	events, recurringEvents, err = u.moveOrCopyCancelledRecurringEvents(ctx, events, recurringEvents)
+	if err != nil {
+		return fmt.Errorf("fail to move cancelled recurring events: %w", err)
+	}
+
+	shouldSaveRecurringEvents, eventInstanceMap, err := u.listEventInstancesFromGoogleCalendar(
+		ctx, calendarID, recurringEvents, syncTime)
+	if err != nil {
+		return fmt.Errorf("fail to list event instances from Google Calendar: %w", err)
+	}
+
+	err = u.databaseRepo.RunTransaction(ctx, func(ctx context.Context, tx repository.DatabaseTransaction) error {
+
+		u.logger.Trace(ctx, "start transaction")
+
+		if err := tx.LockCalendar(ctx, calendarID); err != nil {
+			return fmt.Errorf("fail to lock calendar: %w", err)
+		}
+
+		updatedEventCount := 0
+
+		// events には定期イベントの個別イベントが含まれる可能性があるため、先に定期イベントを登録する
+		for _, recurringEvent := range shouldSaveRecurringEvents {
+			u.logger.Tracef(ctx, "sync recurring event: eventID=%q", recurringEvent.ID)
+
+			instances := eventInstanceMap[recurringEvent.ID]
+			cnt, err := tx.SyncRecurringEventAndInstancesWithAfter(
+				ctx, recurringEvent, instances, syncTime.Add(syncEventInstanceFrom))
+			if err != nil {
+				return fmt.Errorf("fail to sync recurring events: %w", err)
+			}
+
+			updatedEventCount += cnt
+		}
+
+		u.logger.Trace(ctx, "sync events")
+		cnt, err := tx.SyncEvents(ctx, calendarID, events)
+		if err != nil {
+			return fmt.Errorf("fail to sync events: %w", err)
+		}
+
+		updatedEventCount += cnt
+
+		if err := tx.CreateSyncHistory(ctx, calendarID, syncTime, nextSyncToken, updatedEventCount); err != nil {
+			return fmt.Errorf("fail to create sync history: %w", err)
+		}
+
+		return nil
+	})
+
+	if err != nil {
+		return fmt.Errorf("fail to run transaction: %w", err)
+	}
+
+	return nil
+}
+
path: api/usecase/sync_usecase.go
user: login: Copilot
body: [nitpick] Consider adding a doc comment explaining this helper’s behavior and return values, especially the distinction between `events` and `recurringEvents` slices.
```suggestion

// listEventsFromGoogleCalendar retrieves events and recurring events from a Google Calendar.
// 
// It first attempts to fetch events using the latest sync token stored in the database. If the sync token
// is outdated or does not exist, it falls back to fetching all events from the calendar.
//
// Returns:
// - events: A slice of non-recurring events retrieved from the calendar.
// - recurringEvents: A slice of recurring events retrieved from the calendar.
// - nextSyncToken: A string representing the next sync token to be used for subsequent sync operations.
// - error: An error object if the operation fails, or nil if successful.
```
created_at: 2025-05-18T08:40:24Z
author_association: NONE

PR comments:
user: login: takuoki
created_at: 2025-05-11T08:26:24Z
body: 下記はざっと対応したつもり。

- db
- domain
- GoogleCalendarRepository

残は下記。

- ~~SyncUsecase~~
- ~~DatabaseRepository, DatabaseTransaction~~
- 定期イベントのUNTILを考慮し、現在有効でない定期イベントについては取得しないようにしたい
- 自動テスト
- 定期的な再展開処理の実装
- ~~不具合対応~~
author_association: OWNER
user: login: takuoki
created_at: 2025-05-17T09:46:27Z
body: #### 実装後の動作確認で見つかった不具合（修正済み）

- 最初は定期ではない単発の予定として登録した後、その予定を定期の予定に変更した場合、events テーブルに単発の予定が残ってしまう（同じ時間の定期の予定も登録されるため、二重登録になる）
- 定期の予定を削除すると、 EventsListCall のレスポンスには recurrence が含まれていないため、単発イベントの削除と判断され、子イベントの削除が実施されない

#### 不具合ではないが認識していなかった挙動

- 定期のイベントで、個別に変更されたイベントが存在している状態において、当該定期イベントを削除すると
    - 個別以外の子イベントは、ステータスが cancelld になるだけ
        - 現実装ではアプリで制御しているが、そもそもの EventsInstancesCall のレスポンスもステータスが変わっているだけ
    - 個別イベントは、calendar_id, event_id, status 以外が全て空（NULL）になる
        - EventsListCall では、ステータスが cancelld のものは、ID以外の他の項目が空で返ってくるため
author_association: OWNER
user: login: takuoki
created_at: 2025-05-18T08:38:32Z
body: 下記の残タスクは別途対応することとし、一旦マージする。
https://github.com/takuoki/google-calendar-sync/pull/6#issuecomment-2869627624
author_association: OWNER