Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/backend-request-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,12 @@ For `POST /api/event`:
4. controller normalizes single vs array payloads
5. controller forwards the change set to `CompassSyncProcessor`
6. controller returns a status-only payload (`{ statusCode: 204 }`) through `res.promise(...)`
7. processor persists and syncs changes, then notifies clients
7. processor:
- loads current Compass DB state
- analyzes the transition into a persistence plan
- applies Compass DB mutations
- executes any Google side effect
- notifies clients after commit

`PUT /api/event/:id` and `DELETE /api/event/:id` follow the same write pattern:

Expand Down
11 changes: 7 additions & 4 deletions docs/common-change-recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ Rule: never treat event shape as web-only unless the field is strictly presentat
## Change Recurring Event Behavior

1. Read `packages/core/src/types/event.types.ts`.
2. Read `packages/backend/src/sync/services/sync/compass.sync.processor.ts`.
3. Read `packages/backend/src/event/classes/compass.event.parser.ts`.
4. Update the relevant transition path.
5. Add focused tests for the exact recurrence transition you changed.
2. Read `docs/recurrence-handling.md`.
3. Read `packages/backend/src/event/classes/compass.event.generator.ts`.
4. Read `packages/backend/src/event/classes/compass.event.parser.ts`.
5. Read `packages/backend/src/event/classes/compass.event.executor.ts`.
6. Read `packages/backend/src/sync/services/sync/compass.sync.processor.ts`.
7. Update the planner, executor, or scope-expansion path that actually owns the behavior.
8. Add focused tests for the exact recurrence transition you changed.

Do not edit recurring behavior from one layer only.

Expand Down
3 changes: 3 additions & 0 deletions docs/event-and-task-domain-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ These are UI-facing categories, not storage categories.

Many sync and parser decisions key off transitions between these states.

For the full recurring-event lifecycle, see [recurrence-handling.md](./recurrence-handling.md).

## Update Scopes

Recurring edits use `RecurringEventUpdateScope`:
Expand Down Expand Up @@ -72,6 +74,7 @@ Primary code:

- `packages/backend/src/event/services/event.service.ts`
- `packages/backend/src/event/classes/compass.event.parser.ts`
- `packages/backend/src/event/classes/compass.event.executor.ts`
- `packages/backend/src/event/classes/compass.event.generator.ts`

## Someday Semantics
Expand Down
8 changes: 7 additions & 1 deletion docs/google-sync-and-websocket-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,20 @@ High-level path:
3. The selected repository writes locally or remotely.
4. Remote event writes hit backend event routes.
5. `EventController` packages the change as a `CompassEvent`.
6. `CompassSyncProcessor.processEvents()` parses the event transition and applies persistence/sync logic.
6. `CompassSyncProcessor.processEvents()`:
- loads the current DB event
- analyzes the transition into a `CompassOperationPlan`
- applies Compass persistence steps
- executes any Google side effect implied by the plan
7. After commit, the backend emits websocket notifications based on whether the change affected normal or someday events.

Primary files:

- `packages/web/src/ducks/events/sagas/event.sagas.ts`
- `packages/web/src/common/repositories/event`
- `packages/backend/src/event/controllers/event.controller.ts`
- `packages/backend/src/event/classes/compass.event.parser.ts`
- `packages/backend/src/event/classes/compass.event.executor.ts`
- `packages/backend/src/sync/services/sync/compass.sync.processor.ts`

## Inbound Flow: Google Notifies Compass About Changes
Expand Down
122 changes: 122 additions & 0 deletions docs/recurrence-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Recurrence Handling

This document explains how Compass models recurring events, how recurring edits are expanded, and how Compass and Google stay in sync after a recurrence change.

## Structural Model

Compass stores recurring events as:

- one base event with `recurrence.rule`
- zero or more generated instances with `recurrence.eventId`

The base event owns the recurrence rule. Instances do not carry their own independent rule in storage; they point back to the base. When the backend returns an instance through normal event reads, it rehydrates recurrence information from the base.

Primary files:

- `packages/core/src/types/event.types.ts`
- `packages/core/src/util/event/compass.event.rrule.ts`
- `packages/backend/src/event/services/event.service.ts`

## Recurrence Categories

Compass sync logic classifies event shape using `Categories_Recurrence`:

- `STANDALONE`
- `RECURRENCE_BASE`
- `RECURRENCE_INSTANCE`
- `STANDALONE_SOMEDAY`
- `RECURRENCE_BASE_SOMEDAY`
- `RECURRENCE_INSTANCE_SOMEDAY`

The Compass sync path treats recurrence handling as a transition problem:

1. build a transition context from the incoming Compass payload plus the current DB event
2. analyze that transition into a plain `CompassOperationPlan`
3. apply Compass persistence steps from the plan
4. execute Google side effects separately if the plan calls for them

Primary files:

- `packages/backend/src/event/classes/compass.event.parser.ts`
- `packages/backend/src/event/classes/compass.event.executor.ts`
- `packages/backend/src/sync/services/sync/compass.sync.processor.ts`

## Update Scopes

Recurring edits start with `RecurringEventUpdateScope`:

- `This Event`
- `This and Following Events`
- `All Events`

`CompassEventFactory` expands those user-facing scopes into one or more normalized `CompassEvent` payloads before sync processing runs.

Examples:

- `This Event` on a recurring instance becomes a single instance update/delete
- `This and Following Events` splits the existing series into:
- a truncated old series
- a new series starting at the edited instance
- `All Events` resolves to a base-series mutation

Primary file:

- `packages/backend/src/event/classes/compass.event.generator.ts`

## How Series Mutations Work

The recurrence planner distinguishes several Compass mutation shapes:

- `CREATE`: create a standalone event or a new series
- `UPDATE`: update one stored event
- `DELETE`: delete one stored event or one full series
- `UPDATE_SERIES`: update base/instance shared fields without rebuilding the series
- `TRUNCATE_SERIES`: delete instances after a new `UNTIL` date, then update the base series
- `RECREATE_SERIES`: delete generated instances, then recreate the series from the new rule

Current split rule:

- if only the RRULE `UNTIL` changed, use `TRUNCATE_SERIES`
- if other recurrence options changed, use `RECREATE_SERIES`
- if no recurrence split is needed, use `UPDATE_SERIES`

This keeps the recurrence interpretation in the planner and the DB mutations in the executor.

## Someday And Provider Semantics

`isSomeday` changes who is treated as the provider of record:

- normal events usually persist with Google provider data and may mirror to Google
- someday events persist as Compass-owned events and skip Google side effects

Transitions between someday and non-someday states are still analyzed as recurrence transitions. The plan decides whether Google should receive `create`, `update`, `delete`, or `none`.

## Google Sync Boundary

The recurrence planner does not call Google directly.

Instead:

- `analyzeCompassTransition(...)` describes the implied Google effect
- `applyCompassPlan(...)` performs only Compass DB mutations
- `CompassSyncProcessor` executes Google create/update/delete after Compass persistence succeeds

Delete-oriented Google effects should prefer the persisted DB `gEventId` when available, then fall back to the incoming payload `gEventId`.

## What To Verify When Changing Recurrence Logic

- transition classification for base, instance, standalone, and someday shapes
- `RecurringEventUpdateScope` expansion in `CompassEventFactory`
- RRULE split behavior for:
- no split
- `UNTIL`-only truncation
- full series recreation
- Google side effects for someday/non-someday transitions
- websocket notifications for calendar vs someday changes

Good test anchors:

- `packages/backend/src/event/classes/compass.event.parser.test.ts`
- `packages/backend/src/event/classes/compass.event.executor.test.ts`
- `packages/backend/src/sync/services/sync/__tests__/compass.sync.processor.all-event.test.ts`
- `packages/backend/src/sync/services/sync/__tests__/compass-sync-processor-this-event/*.test.ts`
187 changes: 187 additions & 0 deletions packages/backend/src/event/classes/compass.event.executor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/** @jest-environment node */
import { ObjectId } from "mongodb";
import {
CalendarProvider,
Categories_Recurrence,
type Schema_Event,
type Schema_Event_Recur_Base,
type WithCompassId,
} from "@core/types/event.types";
import { CompassEventRRule } from "@core/util/event/compass.event.rrule";
import {
createMockBaseEvent,
createMockStandaloneEvent,
} from "@core/util/test/ccal.event.factory";
import {
type CompassApplyResult,
applyCompassPlan,
} from "@backend/event/classes/compass.event.executor";
import { type CompassOperationPlan } from "@backend/event/classes/compass.event.parser";
import * as eventService from "@backend/event/services/event.service";

jest.mock("@backend/event/services/event.service", () => ({
_createCompassEvent: jest.fn(),
_deleteInstancesAfterUntil: jest.fn(),
_deleteSeries: jest.fn(),
_deleteSingleCompassEvent: jest.fn(),
_updateCompassEvent: jest.fn(),
_updateCompassSeries: jest.fn(),
}));

function normalizeEvent(event: Schema_Event) {
return {
...event,
_id: new ObjectId(event._id),
};
}

function buildSummary() {
return {
title: "test event",
transition: [null, "STANDALONE_CONFIRMED"] as [
null,
"STANDALONE_CONFIRMED",
],
category: Categories_Recurrence.STANDALONE,
};
}

function buildTransition(
operation: CompassOperationPlan["operation"],
): CompassApplyResult["summary"] {
return {
...buildSummary(),
operation,
};
}

describe("applyCompassPlan", () => {
beforeEach(() => {
jest.resetAllMocks();
});

it("creates Compass data and returns the persisted event", async () => {
const payload = createMockStandaloneEvent();
const event = normalizeEvent(payload);
const persistedEvent = {
...payload,
updatedAt: new Date(),
} as WithCompassId<Omit<Schema_Event, "_id">>;

jest
.spyOn(eventService, "_createCompassEvent")
.mockResolvedValueOnce(persistedEvent);

const plan: CompassOperationPlan = {
summary: buildSummary(),
operation: "STANDALONE_CREATED",
transitionKey: "NIL->>STANDALONE_CONFIRMED",
provider: CalendarProvider.GOOGLE,
compassMutation: "CREATE",
googleEffect: { type: "create" },
event,
rrule: null,
steps: [{ type: "create", event, rrule: null }],
};

const result = await applyCompassPlan(plan);

expect(eventService._createCompassEvent).toHaveBeenCalledWith(
{ ...event, user: event.user! },
CalendarProvider.GOOGLE,
null,
undefined,
);
expect(result).toEqual({
applied: true,
summary: buildTransition("STANDALONE_CREATED"),
persistedEvent,
googleDeleteEventId: undefined,
});
});

it("truncates a series before updating it", async () => {
const payload = createMockBaseEvent({
recurrence: { rule: ["RRULE:FREQ=WEEKLY;UNTIL=20260124T170000Z"] },
}) as Schema_Event_Recur_Base;
const event = normalizeEvent(payload) as ReturnType<typeof normalizeEvent> &
Schema_Event_Recur_Base;
const rrule = new CompassEventRRule(event as never);
const persistedEvent = {
...payload,
updatedAt: new Date(),
} as WithCompassId<Omit<Schema_Event, "_id">>;

jest
.spyOn(eventService, "_updateCompassSeries")
.mockResolvedValueOnce(persistedEvent);

const until = rrule.options.until!;
const plan: CompassOperationPlan = {
summary: buildSummary(),
operation: "RECURRENCE_BASE_UPDATED",
transitionKey: "RECURRENCE_BASE->>RECURRENCE_BASE_CONFIRMED",
provider: CalendarProvider.GOOGLE,
compassMutation: "TRUNCATE_SERIES",
googleEffect: { type: "update" },
event,
rrule,
steps: [
{
type: "delete_instances_after_until",
userId: event.user!,
baseId: event._id.toString(),
until,
},
{
type: "update_series",
event,
},
],
};

const result = await applyCompassPlan(plan);

expect(eventService._deleteInstancesAfterUntil).toHaveBeenCalledWith(
event.user!,
event._id.toString(),
until,
undefined,
);
expect(eventService._updateCompassSeries).toHaveBeenCalledWith(
{ ...event, user: event.user! },
undefined,
);
expect(result.persistedEvent).toBe(persistedEvent);
});

it("uses the deleted event gEventId when it is available", async () => {
const payload = createMockStandaloneEvent();
const event = normalizeEvent(payload);
const deletedEvent = {
...payload,
gEventId: "deleted-from-db",
updatedAt: new Date(),
} as WithCompassId<Omit<Schema_Event, "_id">>;

jest
.spyOn(eventService, "_deleteSingleCompassEvent")
.mockResolvedValueOnce(deletedEvent);

const plan: CompassOperationPlan = {
summary: buildSummary(),
operation: "STANDALONE_DELETED",
transitionKey: "STANDALONE->>STANDALONE_CANCELLED",
provider: CalendarProvider.GOOGLE,
compassMutation: "DELETE",
googleEffect: { type: "delete", deleteEventId: "fallback-from-plan" },
event,
rrule: null,
steps: [{ type: "delete_single", event }],
};

const result = await applyCompassPlan(plan);

expect(result.googleDeleteEventId).toBe("deleted-from-db");
});
});
Loading
Loading