From 5658794954d2f234b3308da0a539f39598c5cf54 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 16:35:40 +0000 Subject: [PATCH] refactor(sync): split provider-command service and db tests by operation Co-authored-by: Tyler Dane --- .../src/__tests__/helpers/command-scenario.ts | 2 +- .../domain/cloud-command.service.db.test.ts | 2 +- .../sync/src/domain/cloud-command.service.ts | 20 +- ...provider-command.attendees-edit.db.test.ts | 475 ++ ...r-command.attendees-edit.series.db.test.ts | 436 ++ .../domain/provider-command.create.db.test.ts | 433 ++ .../src/domain/provider-command.create.ts | 206 + .../domain/provider-command.delete.db.test.ts | 505 ++ .../src/domain/provider-command.delete.ts | 139 + .../sync/src/domain/provider-command.deps.ts | 47 + .../domain/provider-command.intent-match.ts | 222 + .../src/domain/provider-command.internal.ts | 143 + .../src/domain/provider-command.occurrence.ts | 333 ++ ...der-command.recurring-following.db.test.ts | 487 ++ ...er-command.recurring-occurrence.db.test.ts | 562 +++ .../domain/provider-command.rsvp.db.test.ts | 458 ++ .../provider-command.rsvp.scopes.db.test.ts | 439 ++ .../sync/src/domain/provider-command.rsvp.ts | 317 ++ .../provider-command.series-following.ts | 365 ++ .../provider-command.series-update.db.test.ts | 342 ++ ...ommand.series-update.exceptions.db.test.ts | 513 ++ .../domain/provider-command.series-update.ts | 365 ++ .../provider-command.service.db.test.ts | 4145 ----------------- .../src/domain/provider-command.service.ts | 2419 ---------- ...provider-command.update-managed.db.test.ts | 272 ++ .../domain/provider-command.update.db.test.ts | 430 ++ .../src/domain/provider-command.update.ts | 446 ++ .../stale-command-retry.service.db.test.ts | 2 +- packages/sync/tsconfig.json | 7 +- tsconfig.tests.json | 2 +- 30 files changed, 7957 insertions(+), 6577 deletions(-) create mode 100644 packages/sync/src/domain/provider-command.attendees-edit.db.test.ts create mode 100644 packages/sync/src/domain/provider-command.attendees-edit.series.db.test.ts create mode 100644 packages/sync/src/domain/provider-command.create.db.test.ts create mode 100644 packages/sync/src/domain/provider-command.create.ts create mode 100644 packages/sync/src/domain/provider-command.delete.db.test.ts create mode 100644 packages/sync/src/domain/provider-command.delete.ts create mode 100644 packages/sync/src/domain/provider-command.deps.ts create mode 100644 packages/sync/src/domain/provider-command.intent-match.ts create mode 100644 packages/sync/src/domain/provider-command.internal.ts create mode 100644 packages/sync/src/domain/provider-command.occurrence.ts create mode 100644 packages/sync/src/domain/provider-command.recurring-following.db.test.ts create mode 100644 packages/sync/src/domain/provider-command.recurring-occurrence.db.test.ts create mode 100644 packages/sync/src/domain/provider-command.rsvp.db.test.ts create mode 100644 packages/sync/src/domain/provider-command.rsvp.scopes.db.test.ts create mode 100644 packages/sync/src/domain/provider-command.rsvp.ts create mode 100644 packages/sync/src/domain/provider-command.series-following.ts create mode 100644 packages/sync/src/domain/provider-command.series-update.db.test.ts create mode 100644 packages/sync/src/domain/provider-command.series-update.exceptions.db.test.ts create mode 100644 packages/sync/src/domain/provider-command.series-update.ts delete mode 100644 packages/sync/src/domain/provider-command.service.db.test.ts delete mode 100644 packages/sync/src/domain/provider-command.service.ts create mode 100644 packages/sync/src/domain/provider-command.update-managed.db.test.ts create mode 100644 packages/sync/src/domain/provider-command.update.db.test.ts create mode 100644 packages/sync/src/domain/provider-command.update.ts diff --git a/packages/sync/src/__tests__/helpers/command-scenario.ts b/packages/sync/src/__tests__/helpers/command-scenario.ts index 31bd15b0e3..0a764b4a05 100644 --- a/packages/sync/src/__tests__/helpers/command-scenario.ts +++ b/packages/sync/src/__tests__/helpers/command-scenario.ts @@ -14,7 +14,7 @@ import { seedProviderCalendar } from "@sync/__tests__/helpers/fixtures"; import { type SyncExecutionMode } from "@sync/config/sync.config"; import { type CredentialCustody } from "@sync/credentials/credential-custody.service"; import { type CloudCommandDeps } from "@sync/domain/cloud-command.service"; -import { type ProviderConnectionLookup } from "@sync/domain/provider-command.service"; +import { type ProviderConnectionLookup } from "@sync/domain/provider-command.deps"; import { type AccessTokenSource } from "@sync/domain/provider-write-ladder"; import { type ProviderAuthAdapter, diff --git a/packages/sync/src/domain/cloud-command.service.db.test.ts b/packages/sync/src/domain/cloud-command.service.db.test.ts index d6a903b5e5..6ebdf61405 100644 --- a/packages/sync/src/domain/cloud-command.service.db.test.ts +++ b/packages/sync/src/domain/cloud-command.service.db.test.ts @@ -30,7 +30,7 @@ import { ProviderWriteUnavailableError, submitCloudCommand, } from "@sync/domain/cloud-command.service"; -import { type ProviderConnectionLookup } from "@sync/domain/provider-command.service"; +import { type ProviderConnectionLookup } from "@sync/domain/provider-command.deps"; import { reprojectOccurrences } from "@sync/domain/reproject"; import { type ProviderEvent } from "@sync/providers/provider-event.port"; import { type ProviderEventWriter } from "@sync/providers/provider-event-writer.port"; diff --git a/packages/sync/src/domain/cloud-command.service.ts b/packages/sync/src/domain/cloud-command.service.ts index 7e1e478ded..464541cf31 100644 --- a/packages/sync/src/domain/cloud-command.service.ts +++ b/packages/sync/src/domain/cloud-command.service.ts @@ -14,19 +14,23 @@ import { isFollowingSplitAtSeriesStart, occurrenceScheduleAt, } from "@sync/domain/occurrence-projection"; +import { executeProviderCreate } from "@sync/domain/provider-command.create"; +import { executeProviderDelete } from "@sync/domain/provider-command.delete"; +import { + type ProviderConnectionLookup, + type ProviderDeleteDeps, +} from "@sync/domain/provider-command.deps"; import { - executeProviderCreate, - executeProviderDelete, executeProviderOccurrenceDelete, executeProviderOccurrenceUpdate, - executeProviderRsvp, +} from "@sync/domain/provider-command.occurrence"; +import { executeProviderRsvp } from "@sync/domain/provider-command.rsvp"; +import { executeProviderSeriesFollowingDelete, executeProviderSeriesFollowingUpdate, - executeProviderSeriesUpdate, - executeProviderUpdate, - type ProviderConnectionLookup, - type ProviderDeleteDeps, -} from "@sync/domain/provider-command.service"; +} from "@sync/domain/provider-command.series-following"; +import { executeProviderSeriesUpdate } from "@sync/domain/provider-command.series-update"; +import { executeProviderUpdate } from "@sync/domain/provider-command.update"; import { reprojectOccurrences } from "@sync/domain/reproject"; import { buildRemainderMaster, diff --git a/packages/sync/src/domain/provider-command.attendees-edit.db.test.ts b/packages/sync/src/domain/provider-command.attendees-edit.db.test.ts new file mode 100644 index 0000000000..eb915bf503 --- /dev/null +++ b/packages/sync/src/domain/provider-command.attendees-edit.db.test.ts @@ -0,0 +1,475 @@ +import { faker } from "@faker-js/faker"; +import { type DateTime, type TimeZone } from "@core/types/domain-primitives"; +import { type Attendee } from "@core/types/event-attendance.contracts"; +import { type SyncCommandInput } from "@core/types/sync/command.contracts"; +import { type ProviderEventVersion } from "@core/types/sync/event.contracts"; +import { + type IdempotencyKey, + type ProviderEventId, +} from "@core/types/sync/identity.contracts"; +import { + bindCommandRepos, + COMMAND_NOW, + FakeProviderEventWriter, + newCommandIds, + seedCommandCalendar, + seedLinkedEvent, + tokenSource, +} from "@sync/__tests__/helpers/command-scenario"; +import { setupSyncStorage } from "@sync/__tests__/helpers/storage"; +import { type ProviderConnectionLookup } from "@sync/domain/provider-command.deps"; +import { executeProviderUpdate } from "@sync/domain/provider-command.update"; +import { type ProviderEvent } from "@sync/providers/provider-event.port"; +import { + type ProviderEventWriter, + ProviderWriteError, +} from "@sync/providers/provider-event-writer.port"; +import { findSafetyCanaryHit } from "@sync/safety/safety-canary"; +import { type EventRecord } from "@sync/storage/contracts/event.contracts"; +import { type CommandRepository } from "@sync/storage/repositories/command.repository"; +import { type CredentialRepository } from "@sync/storage/repositories/credential.repository"; +import { type DeletionMarkerRepository } from "@sync/storage/repositories/deletion-marker.repository"; +import { type EventRepository } from "@sync/storage/repositories/event.repository"; +import { type EventOccurrenceRepository } from "@sync/storage/repositories/event-occurrence.repository"; +import { type ProviderCalendarRepository } from "@sync/storage/repositories/provider-calendar.repository"; +import { type SyncResourceRepository } from "@sync/storage/repositories/sync-resource.repository"; +import { type SyncMongoService } from "@sync/storage/sync-mongo.service"; +import { beforeEach, describe, expect, it } from "bun:test"; + +const storage = setupSyncStorage(import.meta.url); +const repos = bindCommandRepos(storage); +const objectId = () => faker.database.mongodbObjectId(); +const now = COMMAND_NOW; + +let _mongo: SyncMongoService; +let commands: CommandRepository; +let events: EventRepository; +let occurrences: EventOccurrenceRepository; +let resources: SyncResourceRepository; +let calendars: ProviderCalendarRepository; +let _markers: DeletionMarkerRepository; +let _credentials: CredentialRepository; + +beforeEach(() => { + _mongo = repos.mongo; + commands = repos.commands; + events = repos.events; + occurrences = repos.occurrences; + resources = repos.resources; + calendars = repos.calendars; + _markers = repos.markers; + _credentials = repos.credentials; +}); + +describe("attendeesEdit replace", () => { + const OWNER = "owner@example.com"; + + const schedule = { + kind: "timed" as const, + start: "2026-07-14T09:00:00-06:00" as DateTime, + end: "2026-07-14T10:00:00-06:00" as DateTime, + timeZone: "America/Denver" as TimeZone, + }; + + const attendee = ( + email: string, + responseStatus: Attendee["responseStatus"] = "needsAction", + displayName: string | null = null, + ): Attendee => ({ email, displayName, responseStatus }); + + const contentWith = ( + title: string, + opts: { + organizer?: { email: string; displayName: string | null } | null; + attendees?: Attendee[]; + } = {}, + ) => ({ + title, + description: "", + location: null, + organizer: opts.organizer ?? null, + attendees: opts.attendees ?? [], + conference: null, + }); + + const connectionsWith = (email: string | null): ProviderConnectionLookup => ({ + findById: async () => ({ account: { email }, provider: "google" }), + }); + const missingConnection: ProviderConnectionLookup = { + findById: async () => null, + }; + + const deps = ( + writer: ProviderEventWriter, + connections: ProviderConnectionLookup, + ) => ({ + commands, + events, + occurrences, + resources, + connections, + writer, + custody: tokenSource(), + }); + + const seedLinked = async (opts: { + organizer?: { email: string; displayName: string | null } | null; + storedAttendees?: Attendee[]; + recurrence?: { kind: "seriesMaster"; rules: string[] }; + }) => { + const ids = newCommandIds(); + const calendar = await seedCommandCalendar(calendars, ids); + const event = await seedLinkedEvent(events, { + ids, + calendarId: calendar._id, + content: contentWith("Old", { + organizer: opts.organizer, + attendees: opts.storedAttendees, + }), + schedule, + recurrence: opts.recurrence ?? { kind: "single" }, + now: now(), + }); + return { + tenantId: ids.tenantId, + principalId: ids.principalId, + calendar, + event, + }; + }; + + const replaceCommand = async ( + event: EventRecord, + opts: { + title?: string; + attendees: Attendee[]; + attendeesEdit?: "replace" | "preserve"; + scope?: string; + recurrenceId?: string | null; + recurrence?: unknown; + }, + ) => + ( + await commands.submit({ + tenantId: event.tenantId, + principalId: event.principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId: event._id, + input: { + kind: "update", + invitation: "all", + attendeesEdit: opts.attendeesEdit ?? "replace", + content: contentWith(opts.title ?? "Old", { + attendees: opts.attendees, + }), + schedule, + recurrence: opts.recurrence ?? { kind: "preserve" }, + scope: opts.scope ?? "all", + recurrenceId: opts.recurrenceId ?? null, + } as unknown as SyncCommandInput, + expectedVersion: "etag-1" as never, + }) + ).record; + + const providerSingle = ( + title: string, + version: string, + attendees: Attendee[], + ): ProviderEvent => ({ + kind: "event", + providerEventId: "g-evt-1" as ProviderEventId, + providerVersion: version, + providerUpdatedAt: null, + content: contentWith(title, { + organizer: { email: OWNER, displayName: null }, + attendees, + }), + schedule, + busy: true, + recurrence: { kind: "single" }, + }); + + it("merges the intent against freshly fetched provider state and patches the full set", async () => { + // Acceptance "Normal": add one attendee to an event with three existing. + // The stored record is STALE (everyone needsAction); the provider copy has + // newer RSVPs that must survive the replace. + const { tenantId, principalId, calendar, event } = await seedLinked({ + organizer: { email: OWNER, displayName: null }, + storedAttendees: [ + attendee("a@example.com"), + attendee("b@example.com"), + attendee("c@example.com"), + ], + }); + const command = await replaceCommand(event, { + attendees: [ + attendee("a@example.com"), + attendee("b@example.com"), + attendee("c@example.com"), + attendee("d@example.com", "needsAction", "Dee"), + ], + }); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerSingle("Old", "etag-1", [ + attendee("a@example.com", "accepted"), + attendee("b@example.com", "needsAction"), + attendee("c@example.com", "declined", "Cee"), + ]); + + const result = await executeProviderUpdate( + // Case-insensitive: the connection reports the account email cased + // differently than the stored organizer. + deps(writer, connectionsWith("Owner@Example.COM")), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + const expectedMerged = [ + attendee("a@example.com", "accepted"), + attendee("b@example.com", "needsAction"), + attendee("c@example.com", "declined", "Cee"), + attendee("d@example.com", "needsAction", "Dee"), + ]; + expect(writer.patchCalls).toHaveLength(1); + expect(writer.patchCalls[0]!.attendees).toEqual(expectedMerged); + expect(writer.patchCalls[0]!.invitation).toBe("all"); + // The merged membership lands on the sync record at confirm, so reads + // reflect it before the next Google round-trip. + const stored = await events.findById(tenantId, principalId, event._id); + expect(stored?.content.attendees).toEqual(expectedMerged); + }); + + it("replaces with an empty set to remove every guest", async () => { + const { tenantId, principalId, calendar, event } = await seedLinked({ + organizer: { email: OWNER, displayName: null }, + storedAttendees: [attendee("a@example.com", "accepted")], + }); + const command = await replaceCommand(event, { attendees: [] }); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerSingle("Old", "etag-1", [ + attendee("a@example.com", "accepted"), + ]); + + const result = await executeProviderUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls[0]!.attendees).toEqual([]); + const stored = await events.findById(tenantId, principalId, event._id); + expect(stored?.content.attendees).toEqual([]); + }); + + it("fails a non-organizer replace typed, before any provider call", async () => { + const { calendar, event } = await seedLinked({ + organizer: { email: "someone-else@example.com", displayName: null }, + storedAttendees: [attendee("a@example.com", "accepted")], + }); + const command = await replaceCommand(event, { + attendees: [attendee("a@example.com"), attendee("b@example.com")], + }); + const writer = new FakeProviderEventWriter(); + + const result = await executeProviderUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + // No provider call of any kind — not even the replay-detection fetch. + expect(writer.fetchCalls).toHaveLength(0); + expect(writer.patchCalls).toHaveLength(0); + // The failure surface the command route logs from (and the SSE notices + // derive from) carries no attendee JSON or event content. + expect(findSafetyCanaryHit(result.outcome)).toBeNull(); + expect( + findSafetyCanaryHit( + `Command ${result._id} (${result.input.kind} ${result.eventId}) failed: ${ + result.outcome.state === "failed" && result.outcome.failureReason + }`, + ), + ).toBeNull(); + }); + + it("fails closed when the connection cannot be resolved", async () => { + const { calendar, event } = await seedLinked({ + organizer: { email: OWNER, displayName: null }, + }); + const command = await replaceCommand(event, { + attendees: [attendee("a@example.com")], + }); + const writer = new FakeProviderEventWriter(); + + const result = await executeProviderUpdate( + deps(writer, missingConnection), + command, + event, + calendar, + now, + ); + + expect(result.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + expect(writer.fetchCalls).toHaveLength(0); + }); + + it("allows a replace when no organizer is stored yet", async () => { + // A Compass-created event that has never had guests carries no organizer; + // the connection's own account organizes it. + const { calendar, event } = await seedLinked({ organizer: null }); + const command = await replaceCommand(event, { + attendees: [attendee("a@example.com")], + }); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerSingle("Old", "etag-1", []); + + const result = await executeProviderUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls[0]!.attendees).toEqual([ + attendee("a@example.com"), + ]); + }); + + it("confirms a landed attendee-only edit on replay — email sets, order- and status-insensitive", async () => { + const { calendar, event } = await seedLinked({ + organizer: { email: OWNER, displayName: null }, + storedAttendees: [attendee("a@example.com")], + }); + const command = await replaceCommand(event, { + attendees: [attendee("a@example.com"), attendee("b@example.com")], + }); + const writer = new FakeProviderEventWriter(); + // The prior attempt landed; since then the provider reordered the list + // and one guest RSVP'd. Same membership => replay, never a second write. + writer.fetched = providerSingle("Old", "etag-7", [ + attendee("b@example.com", "accepted"), + attendee("A@Example.com", "declined"), + ]); + + const result = await executeProviderUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(0); + expect( + result.outcome.state === "confirmed" && result.outcome.providerVersion, + ).toBe("etag-7" as ProviderEventVersion); + }); + + it("still patches when the landed membership differs from the intent", async () => { + const { calendar, event } = await seedLinked({ + organizer: { email: OWNER, displayName: null }, + }); + const command = await replaceCommand(event, { + attendees: [attendee("a@example.com"), attendee("b@example.com")], + }); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerSingle("Old", "etag-1", [ + attendee("a@example.com", "accepted"), + attendee("c@example.com", "accepted"), + ]); + + const result = await executeProviderUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(1); + expect(writer.patchCalls[0]!.attendees).toEqual([ + attendee("a@example.com", "accepted"), + attendee("b@example.com"), + ]); + }); + + it("keeps a preserve command byte-identical: no attendees on the patch, stored list untouched", async () => { + const storedAttendees = [attendee("kept@example.com", "accepted")]; + const { tenantId, principalId, calendar, event } = await seedLinked({ + organizer: { email: OWNER, displayName: null }, + storedAttendees, + }); + // The browser echoes full content on legacy updates — attendees included — + // but "preserve" must not turn that into a guest write. + const command = await replaceCommand(event, { + title: "Renamed", + attendees: [attendee("stray@example.com")], + attendeesEdit: "preserve", + }); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerSingle("Old", "etag-1", [ + attendee("kept@example.com", "accepted"), + attendee("provider-only@example.com", "tentative"), + ]); + + const result = await executeProviderUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(1); + expect(writer.patchCalls[0]).not.toHaveProperty("attendees"); + expect(writer.patchCalls[0]!.content.attendees).toEqual(storedAttendees); + // The stored record keeps its own attendee list (mergeUpdateContent), not + // the command's echoed one. + const stored = await events.findById(tenantId, principalId, event._id); + expect(stored?.content.attendees).toEqual(storedAttendees); + expect(stored?.content.title).toBe("Renamed"); + }); + + it("leaves a replace pending on a transient fetch failure, with no patch", async () => { + // Acceptance "Tool failure": the provider fetch fails transiently. + const { calendar, event } = await seedLinked({ + organizer: { email: OWNER, displayName: null }, + }); + const command = await replaceCommand(event, { + attendees: [attendee("a@example.com")], + }); + const writer = new FakeProviderEventWriter(); + writer.fetchError = new ProviderWriteError("transient", "blip"); + + const result = await executeProviderUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("pending"); + expect(writer.patchCalls).toHaveLength(0); + }); +}); diff --git a/packages/sync/src/domain/provider-command.attendees-edit.series.db.test.ts b/packages/sync/src/domain/provider-command.attendees-edit.series.db.test.ts new file mode 100644 index 0000000000..ac06c268e9 --- /dev/null +++ b/packages/sync/src/domain/provider-command.attendees-edit.series.db.test.ts @@ -0,0 +1,436 @@ +import { faker } from "@faker-js/faker"; +import { + type DateTime, + type EventId, + type TimeZone, +} from "@core/types/domain-primitives"; +import { type Attendee } from "@core/types/event-attendance.contracts"; +import { type SyncCommandInput } from "@core/types/sync/command.contracts"; +import { + type ConnectionId, + type IdempotencyKey, + type PrincipalId, + type ProviderEventId, + type TenantId, +} from "@core/types/sync/identity.contracts"; +import { + bindCommandRepos, + COMMAND_NOW, + FakeProviderEventWriter, + newCommandIds, + seedCommandCalendar, + seedLinkedEvent, + tokenSource, +} from "@sync/__tests__/helpers/command-scenario"; +import { setupSyncStorage } from "@sync/__tests__/helpers/storage"; +import { executeProviderCreate } from "@sync/domain/provider-command.create"; +import { type ProviderConnectionLookup } from "@sync/domain/provider-command.deps"; +import { executeProviderOccurrenceUpdate } from "@sync/domain/provider-command.occurrence"; +import { executeProviderSeriesFollowingUpdate } from "@sync/domain/provider-command.series-following"; +import { executeProviderSeriesUpdate } from "@sync/domain/provider-command.series-update"; +import { type ProviderEvent } from "@sync/providers/provider-event.port"; +import { type ProviderEventWriter } from "@sync/providers/provider-event-writer.port"; +import { type EventRecord } from "@sync/storage/contracts/event.contracts"; +import { type CommandRepository } from "@sync/storage/repositories/command.repository"; +import { type CredentialRepository } from "@sync/storage/repositories/credential.repository"; +import { type DeletionMarkerRepository } from "@sync/storage/repositories/deletion-marker.repository"; +import { type EventRepository } from "@sync/storage/repositories/event.repository"; +import { type EventOccurrenceRepository } from "@sync/storage/repositories/event-occurrence.repository"; +import { type ProviderCalendarRepository } from "@sync/storage/repositories/provider-calendar.repository"; +import { type SyncResourceRepository } from "@sync/storage/repositories/sync-resource.repository"; +import { type SyncMongoService } from "@sync/storage/sync-mongo.service"; +import { beforeEach, describe, expect, it } from "bun:test"; + +const storage = setupSyncStorage(import.meta.url); +const repos = bindCommandRepos(storage); +const objectId = () => faker.database.mongodbObjectId(); +const now = COMMAND_NOW; + +let _mongo: SyncMongoService; +let commands: CommandRepository; +let events: EventRepository; +let occurrences: EventOccurrenceRepository; +let resources: SyncResourceRepository; +let calendars: ProviderCalendarRepository; +let _markers: DeletionMarkerRepository; +let _credentials: CredentialRepository; + +beforeEach(() => { + _mongo = repos.mongo; + commands = repos.commands; + events = repos.events; + occurrences = repos.occurrences; + resources = repos.resources; + calendars = repos.calendars; + _markers = repos.markers; + _credentials = repos.credentials; +}); + +describe("attendeesEdit replace", () => { + const OWNER = "owner@example.com"; + + const schedule = { + kind: "timed" as const, + start: "2026-07-14T09:00:00-06:00" as DateTime, + end: "2026-07-14T10:00:00-06:00" as DateTime, + timeZone: "America/Denver" as TimeZone, + }; + + const attendee = ( + email: string, + responseStatus: Attendee["responseStatus"] = "needsAction", + displayName: string | null = null, + ): Attendee => ({ email, displayName, responseStatus }); + + const contentWith = ( + title: string, + opts: { + organizer?: { email: string; displayName: string | null } | null; + attendees?: Attendee[]; + } = {}, + ) => ({ + title, + description: "", + location: null, + organizer: opts.organizer ?? null, + attendees: opts.attendees ?? [], + conference: null, + }); + + const connectionsWith = (email: string | null): ProviderConnectionLookup => ({ + findById: async () => ({ account: { email }, provider: "google" }), + }); + const _missingConnection: ProviderConnectionLookup = { + findById: async () => null, + }; + + const deps = ( + writer: ProviderEventWriter, + connections: ProviderConnectionLookup, + ) => ({ + commands, + events, + occurrences, + resources, + connections, + writer, + custody: tokenSource(), + }); + + const seedLinked = async (opts: { + organizer?: { email: string; displayName: string | null } | null; + storedAttendees?: Attendee[]; + recurrence?: { kind: "seriesMaster"; rules: string[] }; + }) => { + const ids = newCommandIds(); + const calendar = await seedCommandCalendar(calendars, ids); + const event = await seedLinkedEvent(events, { + ids, + calendarId: calendar._id, + content: contentWith("Old", { + organizer: opts.organizer, + attendees: opts.storedAttendees, + }), + schedule, + recurrence: opts.recurrence ?? { kind: "single" }, + now: now(), + }); + return { + tenantId: ids.tenantId, + principalId: ids.principalId, + calendar, + event, + }; + }; + + const replaceCommand = async ( + event: EventRecord, + opts: { + title?: string; + attendees: Attendee[]; + attendeesEdit?: "replace" | "preserve"; + scope?: string; + recurrenceId?: string | null; + recurrence?: unknown; + }, + ) => + ( + await commands.submit({ + tenantId: event.tenantId, + principalId: event.principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId: event._id, + input: { + kind: "update", + invitation: "all", + attendeesEdit: opts.attendeesEdit ?? "replace", + content: contentWith(opts.title ?? "Old", { + attendees: opts.attendees, + }), + schedule, + recurrence: opts.recurrence ?? { kind: "preserve" }, + scope: opts.scope ?? "all", + recurrenceId: opts.recurrenceId ?? null, + } as unknown as SyncCommandInput, + expectedVersion: "etag-1" as never, + }) + ).record; + + const providerSingle = ( + title: string, + version: string, + attendees: Attendee[], + ): ProviderEvent => ({ + kind: "event", + providerEventId: "g-evt-1" as ProviderEventId, + providerVersion: version, + providerUpdatedAt: null, + content: contentWith(title, { + organizer: { email: OWNER, displayName: null }, + attendees, + }), + schedule, + busy: true, + recurrence: { kind: "single" }, + }); + + it("merges and patches the guest list on a series edit-all", async () => { + const weekly4 = ["RRULE:FREQ=WEEKLY;COUNT=4"]; + const { tenantId, principalId, calendar, event } = await seedLinked({ + organizer: { email: OWNER, displayName: null }, + storedAttendees: [attendee("a@example.com")], + recurrence: { kind: "seriesMaster", rules: weekly4 }, + }); + const command = await replaceCommand(event, { + attendees: [attendee("a@example.com"), attendee("b@example.com")], + }); + const writer = new FakeProviderEventWriter(); + writer.fetched = { + ...providerSingle("Old", "etag-1", [ + attendee("a@example.com", "accepted"), + ]), + recurrence: { kind: "seriesMaster", rules: weekly4 }, + }; + + const result = await executeProviderSeriesUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(1); + expect(writer.patchCalls[0]!.attendees).toEqual([ + attendee("a@example.com", "accepted"), + attendee("b@example.com"), + ]); + const stored = await events.findById(tenantId, principalId, event._id); + expect(stored?.content.attendees).toEqual([ + attendee("a@example.com", "accepted"), + attendee("b@example.com"), + ]); + }); + + it("fails a non-organizer series edit-all replace before any provider call", async () => { + const { calendar, event } = await seedLinked({ + organizer: { email: "someone-else@example.com", displayName: null }, + recurrence: { + kind: "seriesMaster", + rules: ["RRULE:FREQ=WEEKLY;COUNT=4"], + }, + }); + const command = await replaceCommand(event, { + attendees: [attendee("a@example.com")], + }); + const writer = new FakeProviderEventWriter(); + + const result = await executeProviderSeriesUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + expect(writer.fetchCalls).toHaveLength(0); + expect(writer.patchCalls).toHaveLength(0); + }); + + it("refuses a replace on a scope-this occurrence edit (whole-event only in v1)", async () => { + const { calendar, event } = await seedLinked({ + organizer: { email: OWNER, displayName: null }, + recurrence: { + kind: "seriesMaster", + rules: ["RRULE:FREQ=WEEKLY;COUNT=4"], + }, + }); + const command = await replaceCommand(event, { + attendees: [attendee("a@example.com")], + scope: "this", + recurrenceId: "2026-07-21T15:00:00.000Z", + }); + const writer = new FakeProviderEventWriter(); + + const result = await executeProviderOccurrenceUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + expect(writer.patchCalls).toHaveLength(0); + }); + + it("refuses a replace on a thisAndFollowing split", async () => { + const { tenantId, principalId, calendar, event } = await seedLinked({ + organizer: { email: OWNER, displayName: null }, + recurrence: { + kind: "seriesMaster", + rules: ["RRULE:FREQ=WEEKLY;COUNT=4"], + }, + }); + const command = await replaceCommand(event, { + attendees: [attendee("a@example.com")], + scope: "thisAndFollowing", + recurrenceId: "2026-07-21T15:00:00.000Z", + }); + const writer = new FakeProviderEventWriter(); + + const result = await executeProviderSeriesFollowingUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + expect(writer.patchCalls).toHaveLength(0); + // Refused before the split touched anything: the master's rules are + // untruncated. + const stored = await events.findById(tenantId, principalId, event._id); + expect(stored?.recurrence).toEqual({ + kind: "seriesMaster", + rules: ["RRULE:FREQ=WEEKLY;COUNT=4"], + }); + }); + + it("emits every intended guest as needsAction on create and stores them", async () => { + const tenantId = objectId() as TenantId; + const principalId = objectId() as PrincipalId; + const connectionId = objectId() as ConnectionId; + const calendar = await seedCommandCalendar(calendars, { + tenantId, + principalId, + connectionId, + }); + const { record: command } = await commands.submit({ + tenantId, + principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId: objectId() as EventId, + input: { + kind: "create", + calendarId: calendar._id, + invitation: "all", + attendeesEdit: "replace", + // The command may carry stray statuses; a create normalizes every + // guest to needsAction (nobody has answered a brand-new invitation). + content: contentWith("Kickoff", { + attendees: [ + attendee("a@example.com", "accepted", "Aye"), + attendee("b@example.com"), + ], + }), + schedule, + recurrence: { kind: "single" }, + } as unknown as SyncCommandInput, + expectedVersion: null, + }); + const writer = new FakeProviderEventWriter(); + + const result = await executeProviderCreate( + deps(writer, connectionsWith(OWNER)), + command, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + const expected = [ + attendee("a@example.com", "needsAction", "Aye"), + attendee("b@example.com"), + ]; + expect(writer.calls).toHaveLength(1); + expect(writer.calls[0]!.attendees).toEqual(expected); + expect(writer.calls[0]!.invitation).toBe("all"); + const stored = await events.findById( + tenantId, + principalId, + command.eventId, + ); + expect(stored?.content.attendees).toEqual(expected); + }); + + it("keeps a legacy create byte-identical: no attendees on the provider write", async () => { + const tenantId = objectId() as TenantId; + const principalId = objectId() as PrincipalId; + const connectionId = objectId() as ConnectionId; + const calendar = await seedCommandCalendar(calendars, { + tenantId, + principalId, + connectionId, + }); + const { record: command } = await commands.submit({ + tenantId, + principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId: objectId() as EventId, + // No attendeesEdit: the schema defaults it to "preserve" (legacy). + input: { + kind: "create", + calendarId: calendar._id, + invitation: "none", + content: contentWith("Plain"), + schedule, + recurrence: { kind: "single" }, + } as unknown as SyncCommandInput, + expectedVersion: null, + }); + const writer = new FakeProviderEventWriter(); + + const result = await executeProviderCreate( + deps(writer, connectionsWith(OWNER)), + command, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(command.input.kind === "create" && command.input.attendeesEdit).toBe( + "preserve", + ); + expect(writer.calls[0]).not.toHaveProperty("attendees"); + }); +}); + +// WP-07: rsvp command execution — rewrite ONLY the self attendee entry +// (matched case-insensitively by the connection's account email) against +// freshly fetched provider state, patch the full merged list with +// sendUpdates "none", target the master for scope "all" and the resolved +// Google instance for scope "this", replay without a second write, and fail +// typed (unsupportedCapability) on every guard. diff --git a/packages/sync/src/domain/provider-command.create.db.test.ts b/packages/sync/src/domain/provider-command.create.db.test.ts new file mode 100644 index 0000000000..7674d667d4 --- /dev/null +++ b/packages/sync/src/domain/provider-command.create.db.test.ts @@ -0,0 +1,433 @@ +import { faker } from "@faker-js/faker"; +import { type SyncCommandInput } from "@core/types/sync/command.contracts"; +import { type ProviderEventVersion } from "@core/types/sync/event.contracts"; +import { + type ProviderCalendarSourceId, + type ProviderEventId, +} from "@core/types/sync/identity.contracts"; +import { + bindCommandRepos, + COMMAND_NOW, + FakeProviderEventWriter, + failingTokenSource, + newCommandIds, + providerMutationDeps, + RevokedAuthAdapter, + seedCommandCalendar, + storeCommandCredential, + stubConnectionLookup, + TEST_CREDENTIAL_ENCRYPTION_KEY, + tokenSource, +} from "@sync/__tests__/helpers/command-scenario"; +import { setupSyncStorage } from "@sync/__tests__/helpers/storage"; +import { CredentialCustody } from "@sync/credentials/credential-custody.service"; +import { executeProviderCreate } from "@sync/domain/provider-command.create"; +import { ProviderAuthError } from "@sync/providers/provider-auth.port"; +import { ProviderWriteError } from "@sync/providers/provider-event-writer.port"; +import { SYNC_COLLECTIONS } from "@sync/storage/collections"; +import { type CommandRepository } from "@sync/storage/repositories/command.repository"; +import { type CredentialRepository } from "@sync/storage/repositories/credential.repository"; +import { type DeletionMarkerRepository } from "@sync/storage/repositories/deletion-marker.repository"; +import { type EventRepository } from "@sync/storage/repositories/event.repository"; +import { type EventOccurrenceRepository } from "@sync/storage/repositories/event-occurrence.repository"; +import { type ProviderCalendarRepository } from "@sync/storage/repositories/provider-calendar.repository"; +import { type SyncResourceRepository } from "@sync/storage/repositories/sync-resource.repository"; +import { type SyncMongoService } from "@sync/storage/sync-mongo.service"; +import { beforeEach, describe, expect, it } from "bun:test"; + +const storage = setupSyncStorage(import.meta.url); +const repos = bindCommandRepos(storage); +const _objectId = () => faker.database.mongodbObjectId(); +const now = COMMAND_NOW; + +let mongo: SyncMongoService; +let commands: CommandRepository; +let events: EventRepository; +let occurrences: EventOccurrenceRepository; +let resources: SyncResourceRepository; +let calendars: ProviderCalendarRepository; +let _markers: DeletionMarkerRepository; +let credentials: CredentialRepository; + +beforeEach(() => { + mongo = repos.mongo; + commands = repos.commands; + events = repos.events; + occurrences = repos.occurrences; + resources = repos.resources; + calendars = repos.calendars; + _markers = repos.markers; + credentials = repos.credentials; +}); + +describe("executeProviderCreate", () => { + const createInput = ( + calendarId: string, + invitation = "none", + ): SyncCommandInput => + ({ + kind: "create", + calendarId, + invitation, + content: { + title: "Sync me", + description: "", + location: null, + organizer: null, + attendees: [], + conference: null, + }, + schedule: { + kind: "timed", + start: "2026-07-14T09:00:00-06:00", + end: "2026-07-14T10:00:00-06:00", + timeZone: "America/Denver", + }, + recurrence: { kind: "single" }, + }) as unknown as SyncCommandInput; + + // Seed a pending create command plus its target provider calendar, and return + // both with the fake dependencies wired up. + const seed = async (invitation = "none") => { + const ids = newCommandIds(); + const calendar = await seedCommandCalendar(calendars, ids, { + providerCalendarId: + "primary@group.calendar.google.com" as ProviderCalendarSourceId, + }); + const { record: command } = await commands.submit({ + tenantId: ids.tenantId, + principalId: ids.principalId, + idempotencyKey: ids.idempotencyKey, + eventId: ids.eventId, + input: createInput(calendar._id, invitation), + expectedVersion: null, + }); + return { + tenantId: ids.tenantId, + principalId: ids.principalId, + calendar, + command, + }; + }; + + it("writes to the provider, commits its identity, and confirms", async () => { + const { tenantId, principalId, calendar, command } = await seed(); + const writer = new FakeProviderEventWriter(); + + const result = await executeProviderCreate( + { + commands, + events, + occurrences, + resources, + connections: stubConnectionLookup(), + writer, + custody: tokenSource(), + }, + command, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect( + result.outcome.state === "confirmed" && result.outcome.providerEventId, + ).toBe("g-evt-1" as ProviderEventId); + + // Called with the raw provider calendar id and the deterministic event id. + expect(writer.calls).toHaveLength(1); + expect(writer.calls[0]!.calendarId).toBe(calendar.providerCalendarId); + expect(writer.calls[0]!.providerEventId).toBe(command.eventId); + + const stored = await events.findById( + tenantId, + principalId, + command.eventId, + ); + expect(stored?.connectionId).toBe(calendar.connectionId); + expect(stored?.providerEventId).toBe("g-evt-1" as ProviderEventId); + expect(stored?.providerVersion).toBe("etag-1" as ProviderEventVersion); + expect(stored?.deliveryState).toBe("confirmed"); + expect(stored?.providerMetadata).toBeNull(); + + // The provider-linked event is projected into the read model. + const occ = await mongo.db + .collection(SYNC_COLLECTIONS.eventOccurrences) + .find({ eventId: command.eventId }) + .toArray(); + expect(occ.map((o) => (o["startAt"] as Date).toISOString())).toEqual([ + "2026-07-14T15:00:00.000Z", + ]); + }); + + it("stores iCalUID from the write result on create", async () => { + const { tenantId, principalId, calendar, command } = await seed(); + const writer = new FakeProviderEventWriter(); + writer.result = { + providerEventId: "g-evt-1" as ProviderEventId, + providerVersion: "etag-1", + icalUid: "g-evt-1@google.com", + }; + + await executeProviderCreate( + { + commands, + events, + occurrences, + resources, + connections: stubConnectionLookup(), + writer, + custody: tokenSource(), + }, + command, + calendar, + now, + ); + + const stored = await events.findById( + tenantId, + principalId, + command.eventId, + ); + expect(stored?.providerMetadata).toEqual({ iCalUID: "g-evt-1@google.com" }); + }); + + it("stores the Meet URL Google minted on create, not the command's null", async () => { + const { tenantId, principalId, calendar, command } = await seed(); + const writer = new FakeProviderEventWriter(); + writer.result = { + providerEventId: "g-evt-1" as ProviderEventId, + providerVersion: "etag-1", + conference: { + url: "https://meet.google.com/abc-defg-hij", + label: "Google Meet", + }, + }; + + await executeProviderCreate( + { + commands, + events, + occurrences, + resources, + connections: stubConnectionLookup(), + writer, + custody: tokenSource(), + }, + command, + calendar, + now, + ); + + const stored = await events.findById( + tenantId, + principalId, + command.eventId, + ); + expect( + command.input.kind === "create" && command.input.content.conference, + ).toBe(null); + expect(stored?.content.conference).toEqual({ + url: "https://meet.google.com/abc-defg-hij", + label: "Google Meet", + }); + }); + + it("passes the caller's invitation intent through to the writer", async () => { + const { calendar, command } = await seed("all"); + const writer = new FakeProviderEventWriter(); + + await executeProviderCreate( + { + commands, + events, + occurrences, + resources, + connections: stubConnectionLookup(), + writer, + custody: tokenSource(), + }, + command, + calendar, + now, + ); + + expect(writer.calls[0]!.invitation).toBe("all"); + }); + + it("converges on one event when executed twice (idempotent write)", async () => { + const { tenantId, principalId, calendar, command } = await seed(); + const writer = new FakeProviderEventWriter(); + const deps = { + commands, + events, + occurrences, + resources, + connections: stubConnectionLookup(), + writer, + custody: tokenSource(), + }; + + await executeProviderCreate(deps, command, calendar, now); + await executeProviderCreate(deps, command, calendar, now); + + const owned = await mongo.db + .collection(SYNC_COLLECTIONS.events) + .find({ tenantId, principalId, calendarId: calendar._id }) + .toArray(); + expect(owned).toHaveLength(1); + }); + + it("projects a create at the calendar's active generation, not zero", async () => { + // 2026-08-01: a repaired calendar reads at generation 1, but creates + // hardcoded their occurrences to generation 0, so a new event saved + // successfully to Google and was then invisible in Compass. That was + // meant to self-heal on the next incremental pull; when the sweeps froze, + // the window stayed open for a day. + const { tenantId, principalId, calendar, command } = await seed(); + const resource = await resources.ensure({ + tenantId, + principalId, + connectionId: calendar.connectionId, + resourceKind: "events", + calendarId: calendar._id, + }); + await resources.startNewGeneration(tenantId, principalId, resource._id); + await resources.activateGeneration(tenantId, principalId, resource._id, 1); + + await executeProviderCreate( + providerMutationDeps( + { commands, events, occurrences, resources }, + new FakeProviderEventWriter(), + ), + command, + calendar, + now, + ); + + // Visible to a read at the generation the calendar actually serves. + const atActive = await mongo.db + .collection(SYNC_COLLECTIONS.events) + .find({ tenantId, principalId, calendarId: calendar._id, generation: 1 }) + .toArray(); + expect(atActive).toHaveLength(1); + expect(String(atActive[0]?.["_id"])).toBe(command.eventId); + }); + + it("leaves the command pending on a transient write failure", async () => { + const { tenantId, principalId, calendar, command } = await seed(); + const writer = new FakeProviderEventWriter(); + writer.error = new ProviderWriteError("transient", "network blip"); + + const result = await executeProviderCreate( + { + commands, + events, + occurrences, + resources, + connections: stubConnectionLookup(), + writer, + custody: tokenSource(), + }, + command, + calendar, + now, + ); + + expect(result.outcome.state).toBe("pending"); + expect( + await events.findById(tenantId, principalId, command.eventId), + ).toBeNull(); + }); + + it("fails the command on a terminal write error", async () => { + const { tenantId, principalId, calendar, command } = await seed(); + const writer = new FakeProviderEventWriter(); + writer.error = new ProviderWriteError("readOnlyCalendar", "read only"); + + const result = await executeProviderCreate( + { + commands, + events, + occurrences, + resources, + connections: stubConnectionLookup(), + writer, + custody: tokenSource(), + }, + command, + calendar, + now, + ); + + expect(result.outcome.state).toBe("failed"); + expect( + result.outcome.state === "failed" && result.outcome.failureReason, + ).toBe("readOnlyCalendar"); + expect( + await events.findById(tenantId, principalId, command.eventId), + ).toBeNull(); + }); + + it("fails the command when the credential is revoked, without writing", async () => { + const { calendar, command } = await seed(); + const writer = new FakeProviderEventWriter(); + await storeCommandCredential(credentials, calendar.connectionId); + const custody = new CredentialCustody( + credentials, + () => + new RevokedAuthAdapter({ + refreshError: new ProviderAuthError( + "authorizationRevoked", + "revoked", + ), + }), + undefined, + undefined, + TEST_CREDENTIAL_ENCRYPTION_KEY, + ); + + const result = await executeProviderCreate( + providerMutationDeps( + { commands, events, occurrences, resources }, + writer, + { custody }, + ), + command, + calendar, + now, + ); + + expect(result.outcome.state).toBe("failed"); + expect( + result.outcome.state === "failed" && result.outcome.failureReason, + ).toBe("authorizationRevoked"); + expect(writer.calls).toHaveLength(0); + expect( + await credentials.findByConnection(calendar.connectionId), + ).toBeNull(); + }); + + it("leaves the command pending on a transient refresh failure", async () => { + const { calendar, command } = await seed(); + const writer = new FakeProviderEventWriter(); + + const result = await executeProviderCreate( + providerMutationDeps( + { commands, events, occurrences, resources }, + writer, + { + custody: failingTokenSource( + new ProviderAuthError("refreshFailed", "temporary"), + ), + }, + ), + command, + calendar, + now, + ); + + expect(result.outcome.state).toBe("pending"); + expect(writer.calls).toHaveLength(0); + }); +}); diff --git a/packages/sync/src/domain/provider-command.create.ts b/packages/sync/src/domain/provider-command.create.ts new file mode 100644 index 0000000000..d607fd7afc --- /dev/null +++ b/packages/sync/src/domain/provider-command.create.ts @@ -0,0 +1,206 @@ +import { type EditableRecurrence } from "@core/types/event.contracts"; +import { type Attendee } from "@core/types/event-attendance.contracts"; +import { type ProviderEventVersion } from "@core/types/sync/event.contracts"; +import { type ProviderEventId } from "@core/types/sync/identity.contracts"; +import { + mergeAttendees, + omitNullColor, +} from "@sync/domain/merge-update-content"; +import { type ProviderMutationDeps } from "@sync/domain/provider-command.deps"; +import { failCommand } from "@sync/domain/provider-command.internal"; +import { + resolveAccessToken, + runProviderWrite, +} from "@sync/domain/provider-write-ladder"; +import { reprojectOccurrences } from "@sync/domain/reproject"; +import { + type ProviderWriteRecurrence, + type ProviderWriteResult, +} from "@sync/providers/provider-event-writer.port"; +import { type CommandRecord } from "@sync/storage/contracts/command.contracts"; +import { type EventRecord } from "@sync/storage/contracts/event.contracts"; +import { type ProviderCalendarRecord } from "@sync/storage/contracts/provider-calendar.contracts"; + +export async function executeProviderCreate( + deps: ProviderMutationDeps, + command: CommandRecord, + calendar: ProviderCalendarRecord, + now: () => Date, +): Promise { + if (command.input.kind !== "create") { + throw new Error("executeProviderCreate requires a create command"); + } + const { input } = command; + + const tokenResult = await resolveAccessToken( + deps.custody, + calendar.connectionId, + ); + if (!tokenResult.ok) { + // A transient refresh failure is retryable, so leave the command pending; a + // revoked or missing credential is terminal. + if (tokenResult.stop.kind === "pending") return command; + return failCommand( + deps, + command, + tokenResult.stop.reason, + calendar.connectionId, + ); + } + const { accessToken } = tokenResult; + + // A create with intended guests (attendeesEdit "replace") merges against an + // EMPTY provider list — the event does not exist yet — so every intended + // guest enters as needsAction. The organizer is not synthesized: Google + // adds the creating account as an accepted organizer-attendee itself, so a + // create body must never be compared to its readback. + const intendedAttendees = + input.attendeesEdit === "replace" + ? mergeAttendees(input.content.attendees, []) + : undefined; + + // Transient failures are safe to retry — the deterministic id keeps the + // eventual retry idempotent. Every other reason is terminal and maps + // straight to a command failure class. + const writeResult = await runProviderWrite(() => + deps.writer.createEvent({ + accessToken, + calendarId: calendar.providerCalendarId, + providerEventId: command.eventId, + content: input.content, + schedule: input.schedule, + recurrence: toProviderWriteRecurrence(input.recurrence), + invitation: input.invitation, + ...(intendedAttendees ? { attendees: intendedAttendees } : {}), + ...(input.createConference ? { createConference: true } : {}), + ...(input.guestsCanInviteOthers !== undefined + ? { guestsCanInviteOthers: input.guestsCanInviteOthers } + : {}), + }), + ); + if (!writeResult.ok) { + if (writeResult.stop.kind === "pending") return command; + return failCommand( + deps, + command, + writeResult.stop.reason, + calendar.connectionId, + ); + } + const result = writeResult.value; + + // Commit the provider identity to the canonical event and project its + // occurrences, then confirm. Both run before confirmation, so a crash leaves + // the command pending and a retry re-runs them idempotently. + // Ask which generation reads will serve this calendar before projecting, so + // a create onto a repaired calendar is visible immediately rather than + // waiting for a pull to reproject it. + const generations = await deps.resources.activeGenerationByCalendar( + command.tenantId, + command.principalId, + [input.calendarId], + ); + const record = buildLinkedEventRecord( + command, + calendar, + result, + now(), + generations.get(input.calendarId) ?? 0, + intendedAttendees, + ); + await deps.events.put(record); + await reprojectOccurrences(deps.occurrences, record, now); + + const confirmed = await deps.commands.updateOutcome( + command.tenantId, + command.principalId, + command._id, + { + state: "confirmed", + providerEventId: result.providerEventId as ProviderEventId, + providerVersion: result.providerVersion as ProviderEventVersion, + }, + command.attemptCount, + ); + return confirmed ?? command; +} + +function buildLinkedEventRecord( + command: CommandRecord, + calendar: ProviderCalendarRecord, + result: ProviderWriteResult, + now: Date, + generation: number, + // The guest list the create actually wrote (merged, all needsAction), so + // the stored record reflects it before the next Google round-trip; absent + // for "preserve"/legacy creates, which store the command content verbatim. + intendedAttendees?: readonly Attendee[], +): EventRecord { + if (command.input.kind !== "create") { + throw new Error("buildLinkedEventRecord requires a create command"); + } + const { input } = command; + const withAttendees = intendedAttendees + ? { ...input.content, attendees: intendedAttendees } + : input.content; + const content = result.conference + ? { ...withAttendees, conference: result.conference } + : withAttendees; + return { + _id: command.eventId, + tenantId: command.tenantId, + principalId: command.principalId, + origin: "compass", + calendarId: input.calendarId, + clientEventId: input.clientEventId, + connectionId: calendar.connectionId, + providerEventId: result.providerEventId as ProviderEventId, + providerVersion: result.providerVersion as ProviderEventVersion, + // The write result carries no provider update time; a later read sets it. + providerUpdatedAt: null, + deliveryState: "confirmed", + providerMetadata: result.icalUid ? { iCalUID: result.icalUid } : null, + content: omitNullColor(content), + schedule: input.schedule, + recurrence: + input.recurrence.kind === "series" + ? { kind: "seriesMaster", rules: input.recurrence.rules } + : { kind: "single" }, + lifecycleState: "active", + // The calendar's active generation, resolved by the caller — NOT a + // hardcoded 0. Reads serve the active generation, so on a calendar a + // repair has already advanced, generation-0 occurrences are invisible. + // That gap used to be left to "the next incremental pull will reproject + // it", which holds only while pulls are running: when the sweeps froze on + // 2026-07-31 the window stayed open for a day and users watched their new + // events save successfully to Google and then vanish from Compass. + generation, + createdAt: now, + updatedAt: now, + confirmedAt: now, + }; +} + +// The provider write port takes the same single|series shape the editable +// recurrence already carries (unlike the stored form, which renames series to +// seriesMaster), so this is a near-identity mapping kept explicit for clarity. +function toProviderWriteRecurrence( + recurrence: EditableRecurrence, +): ProviderWriteRecurrence { + return recurrence.kind === "series" + ? { kind: "series", rules: recurrence.rules } + : { kind: "single" }; +} + +// Apply a Compass-initiated update to an existing provider-linked event. +// +// Replay safety is the hard part: a successful conditional patch changes the +// provider version, so a naive crash-then-retry would re-send the now-stale +// expected version and the provider would reject it as a conflict — misreporting +// an edit that actually landed. So we FETCH the provider's current state first: +// if it already carries this command's intended content, the edit landed on a +// prior attempt and we simply confirm at the current version (no second write). +// Otherwise we patch conditionally; the If-Match precondition turns a genuine +// concurrent external edit into a versionConflict. The content check only gates +// the replay shortcut, so a false miss falls through to the conditional patch +// (a spurious conflict at worst — never a lost external edit). diff --git a/packages/sync/src/domain/provider-command.delete.db.test.ts b/packages/sync/src/domain/provider-command.delete.db.test.ts new file mode 100644 index 0000000000..e8c2d6ac6c --- /dev/null +++ b/packages/sync/src/domain/provider-command.delete.db.test.ts @@ -0,0 +1,505 @@ +import { faker } from "@faker-js/faker"; +import { + type DateTime, + type EventId, + type TimeZone, +} from "@core/types/domain-primitives"; +import { + type ConnectionId, + type IdempotencyKey, + type PrincipalId, + type TenantId, +} from "@core/types/sync/identity.contracts"; +import { + bindCommandRepos, + COMMAND_NOW, + FakeProviderEventWriter, + failingTokenSource, + newCommandIds, + providerDeleteDeps, + seedLinkedEvent, +} from "@sync/__tests__/helpers/command-scenario"; +import { setupSyncStorage } from "@sync/__tests__/helpers/storage"; +import { executeProviderDelete } from "@sync/domain/provider-command.delete"; +import { reprojectOccurrences } from "@sync/domain/reproject"; +import { ProviderAuthError } from "@sync/providers/provider-auth.port"; +import { ProviderWriteError } from "@sync/providers/provider-event-writer.port"; +import { SYNC_COLLECTIONS } from "@sync/storage/collections"; +import { type EventRecord } from "@sync/storage/contracts/event.contracts"; +import { type ProviderCalendarRecord } from "@sync/storage/contracts/provider-calendar.contracts"; +import { type CommandRepository } from "@sync/storage/repositories/command.repository"; +import { type CredentialRepository } from "@sync/storage/repositories/credential.repository"; +import { type DeletionMarkerRepository } from "@sync/storage/repositories/deletion-marker.repository"; +import { type EventRepository } from "@sync/storage/repositories/event.repository"; +import { type EventOccurrenceRepository } from "@sync/storage/repositories/event-occurrence.repository"; +import { type ProviderCalendarRepository } from "@sync/storage/repositories/provider-calendar.repository"; +import { type SyncResourceRepository } from "@sync/storage/repositories/sync-resource.repository"; +import { type SyncMongoService } from "@sync/storage/sync-mongo.service"; +import { beforeEach, describe, expect, it } from "bun:test"; + +const storage = setupSyncStorage(import.meta.url); +const repos = bindCommandRepos(storage); +const objectId = () => faker.database.mongodbObjectId(); +const now = COMMAND_NOW; + +let mongo: SyncMongoService; +let commands: CommandRepository; +let events: EventRepository; +let occurrences: EventOccurrenceRepository; +let resources: SyncResourceRepository; +let _calendars: ProviderCalendarRepository; +let markers: DeletionMarkerRepository; +let _credentials: CredentialRepository; + +beforeEach(() => { + mongo = repos.mongo; + commands = repos.commands; + events = repos.events; + occurrences = repos.occurrences; + resources = repos.resources; + _calendars = repos.calendars; + markers = repos.markers; + _credentials = repos.credentials; +}); + +describe("executeProviderDelete", () => { + const schedule = { + kind: "timed" as const, + start: "2026-07-14T09:00:00-06:00" as DateTime, + end: "2026-07-14T10:00:00-06:00" as DateTime, + timeZone: "America/Denver" as TimeZone, + }; + + // Seed a provider-linked event plus a delete command for it. + const seed = async () => { + const ids = newCommandIds(); + const calendar: ProviderCalendarRecord = { + _id: objectId() as never, + tenantId: ids.tenantId, + principalId: ids.principalId, + connectionId: ids.connectionId, + providerCalendarId: "primary@google.com" as never, + displayName: "Google", + color: null, + active: true, + primary: true, + accessRole: "owner", + capabilities: [], + createdAt: now(), + updatedAt: now(), + } as never; + const event = await seedLinkedEvent(events, { + ids, + calendarId: calendar._id, + content: { + title: "Doomed", + description: "", + location: null, + organizer: null, + attendees: [], + conference: null, + }, + schedule, + recurrence: { kind: "single" }, + now: now(), + }); + const { record: command } = await commands.submit({ + tenantId: ids.tenantId, + principalId: ids.principalId, + idempotencyKey: ids.idempotencyKey, + eventId: event._id, + input: { kind: "delete", invitation: "all", scope: "all" } as never, + expectedVersion: null, + }); + return { + tenantId: ids.tenantId, + principalId: ids.principalId, + calendar, + event, + command, + }; + }; + + it("deletes at the provider, tombstones, removes the local event, and confirms", async () => { + const { tenantId, principalId, calendar, event, command } = await seed(); + const writer = new FakeProviderEventWriter(); + // Project the event first so the delete has occurrences to clear. + await reprojectOccurrences(occurrences, event, now); + const occurrenceCount = () => + mongo.db + .collection(SYNC_COLLECTIONS.eventOccurrences) + .countDocuments({ eventId: event._id }); + expect(await occurrenceCount()).toBe(1); + + const result = await executeProviderDelete( + providerDeleteDeps( + { commands, events, occurrences, resources, markers }, + writer, + ), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.deleteCalls).toHaveLength(1); + expect(writer.deleteCalls[0]!.invitation).toBe("all"); + // Local content is gone, a content-free marker remains, occurrences cleared. + expect(await events.findById(tenantId, principalId, event._id)).toBeNull(); + expect(await occurrenceCount()).toBe(0); + expect( + await markers.exists( + calendar.connectionId, + event.calendarId, + "g-evt-1" as never, + ), + ).toBe(true); + }); + + it("confirms idempotently when the local event is already gone (replay)", async () => { + const { tenantId, principalId, calendar, event, command } = await seed(); + const writer = new FakeProviderEventWriter(); + // Simulate a prior attempt having already removed the local event. + await events.deleteById(tenantId, principalId, event._id); + + const result = await executeProviderDelete( + providerDeleteDeps( + { commands, events, occurrences, resources, markers }, + writer, + ), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + // Nothing to re-delete at the provider — the local absence proves it landed. + expect(writer.deleteCalls).toHaveLength(0); + }); + + it("cascades local series exceptions when deleting a provider series", async () => { + // Staging repro: Google-side instance override stays in Sync after the + // master is deleted with scope=all, and keeps resurfacing in range reads. + const tenantId = objectId() as TenantId; + const principalId = objectId() as PrincipalId; + const connectionId = objectId() as ConnectionId; + const calendar: ProviderCalendarRecord = { + _id: objectId() as never, + tenantId, + principalId, + connectionId, + providerCalendarId: "primary@google.com" as never, + displayName: "Google", + color: null, + active: true, + primary: true, + accessRole: "owner", + capabilities: [], + createdAt: now(), + updatedAt: now(), + } as never; + const masterId = objectId() as EventId; + await events.put({ + _id: masterId, + tenantId, + principalId, + origin: "provider", + calendarId: calendar._id, + clientEventId: null, + connectionId, + providerEventId: "g-series-1" as never, + providerVersion: "etag-1" as never, + providerUpdatedAt: null, + deliveryState: "confirmed", + providerMetadata: null, + content: { + title: "Weekly", + description: "", + location: null, + organizer: null, + attendees: [], + conference: null, + }, + schedule, + recurrence: { + kind: "seriesMaster", + rules: ["RRULE:FREQ=WEEKLY;COUNT=4"], + }, + lifecycleState: "active", + generation: 0, + createdAt: now(), + updatedAt: now(), + confirmedAt: now(), + } as never); + const master = await events.findById(tenantId, principalId, masterId); + if (!master) throw new Error("seed failed to read back the master"); + const exceptionId = objectId() as EventId; + await events.put({ + _id: exceptionId, + tenantId, + principalId, + origin: "provider", + calendarId: calendar._id, + clientEventId: null, + connectionId, + providerEventId: "g-inst-override" as never, + providerVersion: "etag-1" as never, + providerUpdatedAt: null, + deliveryState: "confirmed", + providerMetadata: null, + content: { + title: "Moved instance", + description: "", + location: null, + organizer: null, + attendees: [], + conference: null, + }, + schedule: { + kind: "timed", + start: "2026-07-21T11:00:00-06:00", + end: "2026-07-21T12:00:00-06:00", + timeZone: "America/Denver", + }, + recurrence: { + kind: "exception", + seriesId: masterId, + recurrenceId: "2026-07-21T09:00:00-06:00" as never, + cancelled: false, + }, + lifecycleState: "active", + generation: 0, + createdAt: now(), + updatedAt: now(), + confirmedAt: now(), + } as never); + const exception = await events.findById(tenantId, principalId, exceptionId); + if (!exception) throw new Error("seed failed to read back the exception"); + await reprojectOccurrences(occurrences, master, now); + await reprojectOccurrences(occurrences, exception, now); + const { record: command } = await commands.submit({ + tenantId, + principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId: masterId as EventId, + input: { kind: "delete", invitation: "none", scope: "all" } as never, + expectedVersion: null, + }); + + const result = await executeProviderDelete( + providerDeleteDeps( + { commands, events, occurrences, resources, markers }, + new FakeProviderEventWriter(), + ), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(await events.findById(tenantId, principalId, masterId)).toBeNull(); + expect( + await events.findById(tenantId, principalId, exceptionId), + ).toBeNull(); + expect( + await events.findSeriesExceptions(tenantId, principalId, masterId), + ).toEqual([]); + expect( + await mongo.db + .collection(SYNC_COLLECTIONS.eventOccurrences) + .countDocuments({ eventId: { $in: [masterId, exceptionId] } }), + ).toBe(0); + }); + + it("clears leftover series exceptions on an already-gone master replay", async () => { + const tenantId = objectId() as TenantId; + const principalId = objectId() as PrincipalId; + const connectionId = objectId() as ConnectionId; + const calendar: ProviderCalendarRecord = { + _id: objectId() as never, + tenantId, + principalId, + connectionId, + providerCalendarId: "primary@google.com" as never, + displayName: "Google", + color: null, + active: true, + primary: true, + accessRole: "owner", + capabilities: [], + createdAt: now(), + updatedAt: now(), + } as never; + const masterId = objectId() as EventId; + const exceptionId = objectId() as EventId; + // Master already removed (prior attempt); orphan exception remains. + await events.put({ + _id: exceptionId, + tenantId, + principalId, + origin: "provider", + calendarId: calendar._id, + clientEventId: null, + connectionId, + providerEventId: "g-inst-orphan" as never, + providerVersion: "etag-1" as never, + providerUpdatedAt: null, + deliveryState: "confirmed", + providerMetadata: null, + content: { + title: "Orphan", + description: "", + location: null, + organizer: null, + attendees: [], + conference: null, + }, + schedule, + recurrence: { + kind: "exception", + seriesId: masterId, + recurrenceId: "2026-07-21T09:00:00-06:00" as never, + cancelled: false, + }, + lifecycleState: "active", + generation: 0, + createdAt: now(), + updatedAt: now(), + confirmedAt: now(), + } as never); + const ghostMaster = { + _id: masterId, + tenantId, + principalId, + origin: "provider", + calendarId: calendar._id, + clientEventId: null, + connectionId, + providerEventId: "g-series-1" as never, + providerVersion: "etag-1" as never, + providerUpdatedAt: null, + deliveryState: "confirmed", + providerMetadata: null, + content: { + title: "Weekly", + description: "", + location: null, + organizer: null, + attendees: [], + conference: null, + }, + schedule, + recurrence: { + kind: "seriesMaster", + rules: ["RRULE:FREQ=WEEKLY;COUNT=4"], + }, + lifecycleState: "active", + generation: 0, + createdAt: now(), + updatedAt: now(), + confirmedAt: now(), + } as EventRecord; + const { record: command } = await commands.submit({ + tenantId, + principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId: masterId as EventId, + input: { kind: "delete", invitation: "none", scope: "all" } as never, + expectedVersion: null, + }); + + const result = await executeProviderDelete( + providerDeleteDeps( + { commands, events, occurrences, resources, markers }, + new FakeProviderEventWriter(), + ), + command, + ghostMaster, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect( + await events.findById(tenantId, principalId, exceptionId), + ).toBeNull(); + }); + + it("keeps the event deletionPending and stays pending on a transient failure", async () => { + const { tenantId, principalId, calendar, event, command } = await seed(); + const writer = new FakeProviderEventWriter(); + writer.deleteError = new ProviderWriteError("transient", "blip"); + + const result = await executeProviderDelete( + providerDeleteDeps( + { commands, events, occurrences, resources, markers }, + writer, + ), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("pending"); + const stored = await events.findById(tenantId, principalId, event._id); + expect(stored?.lifecycleState).toBe("deletionPending"); + }); + + it("reverts the event to active and fails on a terminal error", async () => { + const { tenantId, principalId, calendar, event, command } = await seed(); + const writer = new FakeProviderEventWriter(); + writer.deleteError = new ProviderWriteError( + "readOnlyCalendar", + "read only", + ); + + const result = await executeProviderDelete( + providerDeleteDeps( + { commands, events, occurrences, resources, markers }, + writer, + ), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("failed"); + expect( + result.outcome.state === "failed" && result.outcome.failureReason, + ).toBe("readOnlyCalendar"); + // The event is restored — a failed delete must not leave it "deleting". + const stored = await events.findById(tenantId, principalId, event._id); + expect(stored?.lifecycleState).toBe("active"); + }); + + it("reverts and fails without deleting when the credential is revoked", async () => { + const { tenantId, principalId, calendar, event, command } = await seed(); + const writer = new FakeProviderEventWriter(); + + const result = await executeProviderDelete( + providerDeleteDeps( + { commands, events, occurrences, resources, markers }, + writer, + { + custody: failingTokenSource( + new ProviderAuthError("authorizationRevoked", "revoked"), + ), + }, + ), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("failed"); + expect(writer.deleteCalls).toHaveLength(0); + const stored = await events.findById(tenantId, principalId, event._id); + expect(stored?.lifecycleState).toBe("active"); + }); +}); diff --git a/packages/sync/src/domain/provider-command.delete.ts b/packages/sync/src/domain/provider-command.delete.ts new file mode 100644 index 0000000000..f8c6a11432 --- /dev/null +++ b/packages/sync/src/domain/provider-command.delete.ts @@ -0,0 +1,139 @@ +import { type EventId } from "@core/types/domain-primitives"; +import { type ProviderDeleteDeps } from "@sync/domain/provider-command.deps"; +import { + confirmDeletion, + revertAndFail, +} from "@sync/domain/provider-command.internal"; +import { + resolveAccessToken, + runProviderWrite, +} from "@sync/domain/provider-write-ladder"; +import { type CommandRecord } from "@sync/storage/contracts/command.contracts"; +import { type EventRecord } from "@sync/storage/contracts/event.contracts"; +import { type ProviderCalendarRecord } from "@sync/storage/contracts/provider-calendar.contracts"; + +export async function executeProviderDelete( + deps: ProviderDeleteDeps, + command: CommandRecord, + event: EventRecord, + calendar: ProviderCalendarRecord, + now: () => Date, +): Promise { + if (command.input.kind !== "delete") { + throw new Error("executeProviderDelete requires a delete command"); + } + if (!event.connectionId || !event.providerEventId) { + throw new Error("executeProviderDelete requires a linked event"); + } + const { input } = command; + const connectionId = event.connectionId; + const providerEventId = event.providerEventId; + + // Mark the event as being deleted. If it is already gone locally, a prior + // attempt removed it (the marker was written first) — the delete converged. + // Still cascade any leftover series exceptions: older builds removed the + // master without clearing overrides, and a crash between exception cleanup + // and confirm can leave the same residue. + const marked = await deps.events.replaceExisting({ + ...event, + lifecycleState: "deletionPending", + updatedAt: now(), + }); + if (!marked) { + await clearSeriesExceptions(deps, command, command.eventId); + return confirmDeletion(deps, command); + } + + const tokenResult = await resolveAccessToken(deps.custody, connectionId); + if (!tokenResult.ok) { + if (tokenResult.stop.kind === "pending") return command; + return revertAndFail( + deps, + command, + event, + tokenResult.stop.reason, + connectionId, + now, + ); + } + const { accessToken } = tokenResult; + + // Transient: keep the event deletionPending (visibly deleting) and retry. + // Terminal: the delete failed, so restore the event to active rather than + // leaving it stuck showing "deleting". + const deleteResult = await runProviderWrite(() => + deps.writer.deleteEvent({ + accessToken, + calendarId: calendar.providerCalendarId, + providerEventId, + // Unconditional: a cancellation is not conditioned on the version, so an + // unrelated external change never blocks it. + expectedVersion: null, + invitation: input.invitation, + }), + ); + if (!deleteResult.ok) { + if (deleteResult.stop.kind === "pending") return command; + return revertAndFail( + deps, + command, + event, + deleteResult.stop.reason, + connectionId, + now, + ); + } + + // The provider confirmed the deletion. Write the content-free tombstone first + // (so no window exists where the event is gone with no marker), clear the + // master's occurrences, cascade local series exceptions (Google-side instance + // overrides), then remove the master LAST — same crash-safety as the cloud + // series delete / pull cascade. Clearing before deleteById matters: a crash + // after deleteById would otherwise strand occurrence rows, since the retry's + // already-gone (`!marked`) branch confirms without ever clearing them. + await deps.markers.record({ + tenantId: event.tenantId, + principalId: event.principalId, + connectionId, + calendarId: event.calendarId, + providerEventId, + providerVersion: event.providerVersion, + deletionSource: "compass", + deletedAt: now(), + }); + await deps.occurrences.replaceForEvent(event._id, event.generation, []); + await clearSeriesExceptions(deps, command, event._id); + await deps.events.deleteById(event.tenantId, event.principalId, event._id); + return confirmDeletion(deps, command); +} + +// Remove every local exception of a series (occurrences first). Idempotent: +// findSeriesExceptions is empty when the target was a single event or when a +// prior attempt already cleared overrides. Does not call the provider — Google +// series delete already discarded the instances with the master. +async function clearSeriesExceptions( + deps: ProviderDeleteDeps, + command: CommandRecord, + seriesId: EventId, +): Promise { + const exceptions = await deps.events.findSeriesExceptions( + command.tenantId, + command.principalId, + seriesId, + ); + for (const exception of exceptions) { + await deps.occurrences.replaceForEvent( + exception._id, + exception.generation, + [], + ); + await deps.events.deleteById( + command.tenantId, + command.principalId, + exception._id, + ); + } +} + +// Confirm a completed deletion: the event has no live provider target anymore, +// so the confirmed outcome carries no provider identity. diff --git a/packages/sync/src/domain/provider-command.deps.ts b/packages/sync/src/domain/provider-command.deps.ts new file mode 100644 index 0000000000..afaf0e4adf --- /dev/null +++ b/packages/sync/src/domain/provider-command.deps.ts @@ -0,0 +1,47 @@ +import { + type ConnectionId, + type PrincipalId, + type ProviderKind, + type TenantId, +} from "@core/types/sync/identity.contracts"; +import { type AccessTokenSource } from "@sync/domain/provider-write-ladder"; +import { type ProviderEventWriter } from "@sync/providers/provider-event-writer.port"; +import { type CommandRepository } from "@sync/storage/repositories/command.repository"; +import { type DeletionMarkerRepository } from "@sync/storage/repositories/deletion-marker.repository"; +import { type EventRepository } from "@sync/storage/repositories/event.repository"; +import { type EventOccurrenceRepository } from "@sync/storage/repositories/event-occurrence.repository"; +import { type SyncResourceRepository } from "@sync/storage/repositories/sync-resource.repository"; + +// The single connection fact the attendee organizer guard needs, narrowed +// from ProviderConnectionRepository so tests can fake it without a database. +export interface ProviderConnectionLookup { + findById( + tenantId: TenantId, + principalId: PrincipalId, + id: ConnectionId, + ): Promise<{ + readonly account: { readonly email: string | null }; + readonly provider: ProviderKind; + } | null>; +} + +export interface ProviderMutationDeps { + commands: CommandRepository; + events: EventRepository; + // The derived occurrence projection, rebuilt (or cleared, on delete) so a + // provider-linked event appears in range queries. + occurrences: EventOccurrenceRepository; + // Reads serve a calendar's active generation, so a create has to ask which + // generation that is rather than assume the calendar has never been repaired. + resources: SyncResourceRepository; + // Which account the write acts as — a guest-list replace is only valid on + // an event that account organizes (see organizerGuardFailure). + connections: ProviderConnectionLookup; + writer: ProviderEventWriter; + custody: AccessTokenSource; +} + +// Delete also needs the deletion-marker store for the tombstone. +export interface ProviderDeleteDeps extends ProviderMutationDeps { + markers: DeletionMarkerRepository; +} diff --git a/packages/sync/src/domain/provider-command.intent-match.ts b/packages/sync/src/domain/provider-command.intent-match.ts new file mode 100644 index 0000000000..a1b6cbd300 --- /dev/null +++ b/packages/sync/src/domain/provider-command.intent-match.ts @@ -0,0 +1,222 @@ +import { type EventSchedule } from "@core/types/event.contracts"; +import { type Attendee } from "@core/types/event-attendance.contracts"; +import { type RecurrenceEdit } from "@core/types/event-command.contracts"; +import { + type SyncEventContent, + type SyncEventRecurrence, +} from "@core/types/sync/event.contracts"; +import { type ProviderEvent } from "@sync/providers/provider-event.port"; +import { type ProviderWriteRecurrence } from "@sync/providers/provider-event-writer.port"; +import { type CommandRecord } from "@sync/storage/contracts/command.contracts"; +import { type EventRecord } from "@sync/storage/contracts/event.contracts"; + +export function intendedSeriesRecurrence( + recurrence: RecurrenceEdit, + master: EventRecord, +): ProviderWriteRecurrence { + if (recurrence.kind === "series") { + return { kind: "series", rules: recurrence.rules }; + } + if (recurrence.kind === "single") return { kind: "single" }; + return master.recurrence.kind === "seriesMaster" + ? { kind: "series", rules: master.recurrence.rules } + : { kind: "single" }; +} + +// The stored recurrence a series edit-all applies to the local master, mirroring +// intendedSeriesRecurrence in the canonical event's own union. +export function storedSeriesRecurrence( + recurrence: RecurrenceEdit, + master: EventRecord, +): SyncEventRecurrence { + if (recurrence.kind === "series") { + return { kind: "seriesMaster", rules: recurrence.rules }; + } + if (recurrence.kind === "single") return { kind: "single" }; + return master.recurrence; +} + +// Whether the provider's current event already carries this command's intended +// edit — the signal that a prior attempt landed and this is a safe replay. +// Compares ONLY the fields a patch actually writes (title, description, +// location, color, schedule, recurrence — and the guest membership, but only +// when the command intends a guest-list replace). organizer/conference are +// read-reflected, never written by the provider adapter, so they drift +// independently — comparing them would turn a landed edit into a false miss, +// then a stale-version patch, then a spurious versionConflict on a write that +// already succeeded. Attendees are the same by default (an attendee RSVPs +// whenever they like), so they stay out of the comparison for every +// "preserve"/legacy command; a "replace" command DOES write them, so its +// replay check compares membership — as email sets, order-insensitive and +// responseStatus-ignored, because RSVP drift must never block replay (see +// attendeesMatchIntent). Recurrence IS written (a series edit-all changes the +// rules), so it must be compared: a rules-only edit leaves content and +// schedule identical, and without this a false replay would confirm the +// command without ever writing the new rules. A false negative on the +// compared fields is still safe: the replay check falls through to the +// conditional patch, and patchExpectedVersion keeps the submitter's version. +export function matchesIntendedEdit( + current: ProviderEvent, + content: SyncEventContent, + schedule: EventSchedule, + recurrence: ProviderWriteRecurrence, + intendedAttendees?: readonly Attendee[], +): boolean { + // Null on the command means "no color"; treat it like an absent color on + // the provider read so a clear that already landed counts as a replay. + const intendedColor = content.color === null ? undefined : content.color; + return ( + current.content.title === content.title && + current.content.description === content.description && + current.content.location === content.location && + current.content.color === intendedColor && + deepEqual(current.schedule, schedule) && + recurrenceMatches(current.recurrence, recurrence) && + attendeesMatchIntent(current.content.attendees, intendedAttendees) + ); +} + +// The If-Match version for a conditional patch on a fetched provider event. +// The command's expectedVersion is the version its submitter last saw, so a +// patch conditioned on it refuses to overwrite an edit made elsewhere since. +// But a provider can rotate an event's version on its own (Exchange rewrites +// an item's change key seconds after a create; see #3208 and the +// live-provider-smoke), and until the next pull refreshes the stored version +// every conditional edit would fail as a spurious versionConflict. When the +// fetched event still carries exactly what Compass has stored for it (the +// written fields, per matchesIntendedEdit; guest membership only when this +// command replaces it), nobody edited it elsewhere, so the drift is the +// provider's own rotation and the patch conditions on the fresh version. Any +// difference keeps the submitter's version, so a genuine external edit still +// fails as versionConflict. An unconditional command (null) stays +// unconditional. Occurrence patches have no stored instance to compare +// against, so they keep the submitter's version as before. +export function patchExpectedVersion( + command: CommandRecord, + current: ProviderEvent, + stored: EventRecord, + compareAttendees = false, +): string | null { + if (command.expectedVersion === null) return null; + const unchanged = matchesIntendedEdit( + current, + stored.content, + stored.schedule, + storedWriteRecurrence(stored.recurrence), + compareAttendees ? stored.content.attendees : undefined, + ); + return unchanged ? current.providerVersion : command.expectedVersion; +} + +// The recurrence a patch would write for a stored record as it stands: a +// series master carries its rules, an exception addresses one instance, and +// anything else is a single event. +export function storedWriteRecurrence( + recurrence: EventRecord["recurrence"], +): ProviderWriteRecurrence { + if (recurrence.kind === "seriesMaster") { + return { kind: "series", rules: recurrence.rules }; + } + if (recurrence.kind === "exception") return { kind: "instance" }; + return { kind: "single" }; +} + +// Membership comparison for the replay check, entered ONLY when the command +// intends a guest-list replace (undefined = attendees are not part of this +// write; always a match). Email sets, case-insensitive, order-insensitive, +// responseStatus-ignored: a guest RSVPing (or Google reordering the list) +// between a landed patch and its retry must still read as a replay — RSVP +// drift never blocks replay. +export function attendeesMatchIntent( + current: readonly Attendee[], + intended: readonly Attendee[] | undefined, +): boolean { + if (intended === undefined) return true; + const currentEmails = new Set( + current.map(({ email }) => email.toLowerCase()), + ); + const intendedEmails = new Set( + intended.map(({ email }) => email.toLowerCase()), + ); + return ( + currentEmails.size === intendedEmails.size && + [...intendedEmails].every((email) => currentEmails.has(email)) + ); +} + +// Whether the provider's reported recurrence matches the recurrence a write +// would set. A single write expects a non-recurring event; a series write +// expects the same rules the provider now reports on its master. +// +// Rules are compared canonically, not byte-for-byte. A provider can echo a rule +// it stored in a reformatted-but-equivalent form (reordered components, +// different casing), so an exact compare would false-miss a landed edit — and +// because this gates a replay, that miss would re-patch with a now-stale +// expected version and fail a write that already succeeded, leaving the local +// event diverged from the provider. Canonicalizing absorbs that cosmetic drift; +// genuinely different rules still differ, so this never widens a real change +// into a false match. Named wart: an exotic reformatting the canonicalization +// misses (e.g. a provider injecting a non-default WKST we never sent) can still +// false-miss — acceptable because Compass emits simple rules and the only +// consequence is a spurious conflict in the narrow landed-then-retried window. +export function recurrenceMatches( + current: ProviderEvent["recurrence"], + intended: ProviderWriteRecurrence, +): boolean { + // "single" and "instance" both address a non-recurring event; only "series" + // carries rules to compare, so anything else is a bare kind match. + if (intended.kind !== "series") return current.kind === intended.kind; + if (current.kind !== "seriesMaster") return false; + if (current.rules.length !== intended.rules.length) return false; + const currentCanonical = current.rules.map(canonicalRule).sort(); + const intendedCanonical = intended.rules.map(canonicalRule).sort(); + return currentCanonical.every( + (rule, index) => rule === intendedCanonical[index], + ); +} + +// A rule normalized for equality: uppercased, its optional RRULE: prefix +// dropped, and its ;-separated components sorted. This makes the comparison +// order- and case-insensitive without a full RRULE parse (which would drag in +// dtstart defaulting and reformatting of its own). +function canonicalRule(rule: string): string { + return rule + .trim() + .toUpperCase() + .replace(/^RRULE:/, "") + .split(";") + .filter((part) => part.length > 0) + .sort() + .join(";"); +} + +export function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if ( + typeof a !== "object" || + typeof b !== "object" || + a === null || + b === null + ) { + return false; + } + if (Array.isArray(a) !== Array.isArray(b)) return false; + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + return aKeys.every((key) => + deepEqual( + (a as Record)[key], + (b as Record)[key], + ), + ); +} + +// Delete a Compass-initiated provider event. The event is marked deletionPending +// (so it reads as "deleting" while the command is in flight or retrying) BEFORE +// the provider is asked, and its local content is removed only AFTER the +// provider confirms — never delete content before provider confirmation. The +// delete is unconditional: the user's intent to cancel does not hinge on a +// version, and a routine attendee RSVP must not block it. Idempotent: the +// adapter treats an already-absent event as deleted, and the marker + local +// delete are both idempotent, so a retry after a crash converges. diff --git a/packages/sync/src/domain/provider-command.internal.ts b/packages/sync/src/domain/provider-command.internal.ts new file mode 100644 index 0000000000..00d39da694 --- /dev/null +++ b/packages/sync/src/domain/provider-command.internal.ts @@ -0,0 +1,143 @@ +import { type SyncCommandFailureReason } from "@core/types/sync/command.contracts"; +import { + type ConnectionId, + type ProviderEventId, +} from "@core/types/sync/identity.contracts"; +import { type ProviderMutationDeps } from "@sync/domain/provider-command.deps"; +import { runProviderWrite } from "@sync/domain/provider-write-ladder"; +import { type CommandRecord } from "@sync/storage/contracts/command.contracts"; +import { type EventRecord } from "@sync/storage/contracts/event.contracts"; + +export async function failCommand( + deps: ProviderMutationDeps, + command: CommandRecord, + reason: SyncCommandFailureReason, + connectionId: ConnectionId, +): Promise { + const failed = await deps.commands.updateOutcome( + command.tenantId, + command.principalId, + command._id, + { state: "failed", failureReason: reason }, + command.attemptCount, + ); + if (reason === "authorizationRevoked") { + await deps.custody.discardRevoked(connectionId); + } + return failed ?? command; +} + +// Gate for a guest-list replace: only the organizer's copy of an event +// supports rewriting the attendee array, and v1 rejects non-organizer guest +// editing outright (`guestsCanModify` is a documented follow-up). The STORED +// organizer is compared case-insensitively against the connection's account +// email so a non-organizer replace fails typed (unsupportedCapability) BEFORE +// any provider call, fetch included. A null stored organizer passes — it +// means no organizer has ever been read back (e.g. a Compass-created event +// with no guests yet), so the connection's own account organizes it. +// Unverifiable states (missing connection row, or a connection without an +// account email while an organizer exists) fail closed with the same typed +// reason rather than guessing. Returns the failed command, or null when the +// replace may proceed. +export async function organizerGuardFailure( + deps: ProviderMutationDeps, + command: CommandRecord, + event: EventRecord, + connectionId: ConnectionId, +): Promise { + const organizerEmail = event.content.organizer?.email; + if (organizerEmail === undefined) return null; + const connection = await deps.connections.findById( + command.tenantId, + command.principalId, + connectionId, + ); + const accountEmail = connection?.account.email; + if ( + accountEmail != null && + accountEmail.toLowerCase() === organizerEmail.toLowerCase() + ) { + return null; + } + return failCommand(deps, command, "unsupportedCapability", connectionId); +} + +// After a failed override-align patch, continue when the instance is already +// gone (matching deleteEvent's 404-OK). Return a command to stop on; null means +// local cleanup may proceed. The master write may already have landed, so a +// hard fail on a missing override would leave Google mutated and Compass not. +export async function resolveFailedOverrideAlign( + deps: ProviderMutationDeps, + command: CommandRecord, + provider: { + accessToken: string; + calendarId: string; + connectionId: ConnectionId; + }, + providerEventId: string, + reason: SyncCommandFailureReason, +): Promise { + if (reason !== "permanentProviderError") { + return failCommand(deps, command, reason, provider.connectionId); + } + const fetchResult = await runProviderWrite(() => + deps.writer.fetchEvent({ + accessToken: provider.accessToken, + calendarId: provider.calendarId, + providerEventId: providerEventId as ProviderEventId, + }), + ); + if (!fetchResult.ok) { + if (fetchResult.stop.kind === "pending") return command; + return failCommand( + deps, + command, + fetchResult.stop.reason, + provider.connectionId, + ); + } + if (fetchResult.value?.kind === "event") { + return failCommand(deps, command, reason, provider.connectionId); + } + return null; +} + +// Build the canonical event for a provider-linked create: same shape as a cloud +// event but with the provider identity the write returned. calendarId stays the +// Sync provider-calendar id (how the command addressed it); the raw provider +// calendar id is only used for the API call. +export async function confirmDeletion( + deps: ProviderMutationDeps, + command: CommandRecord, +): Promise { + const confirmed = await deps.commands.updateOutcome( + command.tenantId, + command.principalId, + command._id, + { state: "confirmed", providerEventId: null, providerVersion: null }, + command.attemptCount, + ); + return confirmed ?? command; +} + +// Restore a deletionPending event to active, then fail the command. A failed +// delete must not leave the event stuck reading as "deleting". The revert write +// is NOT wrapped in a catch: replaceExisting signals the benign "already gone" +// case with a resolved false (not a throw), so a throw here is a real error — +// letting it propagate keeps the command pending (not falsely failed) and +// retryable, rather than marking it terminally failed with a stuck event. +export async function revertAndFail( + deps: ProviderMutationDeps, + command: CommandRecord, + event: EventRecord, + reason: SyncCommandFailureReason, + connectionId: ConnectionId, + now: () => Date, +): Promise { + await deps.events.replaceExisting({ + ...event, + lifecycleState: "active", + updatedAt: now(), + }); + return failCommand(deps, command, reason, connectionId); +} diff --git a/packages/sync/src/domain/provider-command.occurrence.ts b/packages/sync/src/domain/provider-command.occurrence.ts new file mode 100644 index 0000000000..9806b2a52d --- /dev/null +++ b/packages/sync/src/domain/provider-command.occurrence.ts @@ -0,0 +1,333 @@ +import { type DateTime } from "@core/types/domain-primitives"; +import { type EventSchedule } from "@core/types/event.contracts"; +import { + type ProviderEventVersion, + type SyncEventContent, +} from "@core/types/sync/event.contracts"; +import { type ProviderEventId } from "@core/types/sync/identity.contracts"; +import { + resolveUpdateContent, + resolveUpdateSchedule, +} from "@sync/domain/merge-update-content"; +import { occurrenceScheduleAt } from "@sync/domain/occurrence-projection"; +import { type ProviderMutationDeps } from "@sync/domain/provider-command.deps"; +import { matchesIntendedEdit } from "@sync/domain/provider-command.intent-match"; +import { failCommand } from "@sync/domain/provider-command.internal"; +import { + resolveAccessToken, + runProviderWrite, +} from "@sync/domain/provider-write-ladder"; +import { reprojectOccurrences } from "@sync/domain/reproject"; +import { reprojectMaster } from "@sync/domain/series-exception"; +import { type CommandRecord } from "@sync/storage/contracts/command.contracts"; +import { type EventRecord } from "@sync/storage/contracts/event.contracts"; +import { type ProviderCalendarRecord } from "@sync/storage/contracts/provider-calendar.contracts"; + +export async function executeProviderOccurrenceUpdate( + deps: ProviderMutationDeps, + command: CommandRecord, + master: EventRecord, + calendar: ProviderCalendarRecord, + now: () => Date, +): Promise { + if (command.input.kind !== "update" || command.input.recurrenceId === null) { + throw new Error( + "executeProviderOccurrenceUpdate requires a this-scope update command", + ); + } + if (!master.connectionId || !master.providerEventId) { + throw new Error( + "executeProviderOccurrenceUpdate requires a linked series master", + ); + } + if (master.recurrence.kind !== "seriesMaster") { + throw new Error("executeProviderOccurrenceUpdate requires a series master"); + } + const { input } = command; + const recurrenceId = input.recurrenceId as DateTime; + const connectionId = master.connectionId; + const seriesProviderEventId = master.providerEventId; + + // Guest-list editing is whole-event/whole-series only in v1: a replace on a + // "this" scope has no defined per-occurrence semantics yet, so refuse typed + // rather than silently preserving — dropped intent would read as a + // successful guest edit that never happened. + if (input.attendeesEdit === "replace") { + return failCommand(deps, command, "unsupportedCapability", connectionId); + } + + const tokenResult = await resolveAccessToken(deps.custody, connectionId); + if (!tokenResult.ok) { + if (tokenResult.stop.kind === "pending") return command; + return failCommand(deps, command, tokenResult.stop.reason, connectionId); + } + const { accessToken } = tokenResult; + + const fetchInstanceResult = await runProviderWrite(() => + deps.writer.fetchInstanceAt({ + accessToken, + calendarId: calendar.providerCalendarId, + seriesProviderEventId, + originalStartAt: recurrenceId, + scheduleKind: master.schedule.kind, + }), + ); + if (!fetchInstanceResult.ok) { + if (fetchInstanceResult.stop.kind === "pending") return command; + return failCommand( + deps, + command, + fetchInstanceResult.stop.reason, + connectionId, + ); + } + const instance = + fetchInstanceResult.value?.kind === "event" + ? fetchInstanceResult.value + : null; + // No live instance to override: never materialized at that instant, or + // already cancelled at the provider (see the deferred-gap note above). + if (!instance) { + return failCommand(deps, command, "permanentProviderError", connectionId); + } + + const content = resolveUpdateContent( + master.content, + input.content, + instance.content, + ); + const schedule = resolveUpdateSchedule(input.schedule, instance.schedule); + const providerEventId = instance.providerEventId; + const location = { + accessToken, + calendarId: calendar.providerCalendarId, + providerEventId, + }; + + // An occurrence override is always a standalone provider event, resolved + // off the series by fetchInstanceAt — "instance", not "single": Google + // rejects a `recurrence` key at all on that kind of event (see + // ProviderWriteRecurrence). + if ( + matchesIntendedEdit(instance, content, schedule, { + kind: "instance", + }) + ) { + return commitProviderOccurrenceUpdate( + deps, + command, + master, + recurrenceId, + content, + schedule, + providerEventId, + instance.providerVersion, + now, + ); + } + + const patchResult = await runProviderWrite(() => + deps.writer.patchEvent({ + ...location, + expectedVersion: command.expectedVersion, + content, + schedule, + recurrence: { kind: "instance" }, + invitation: input.invitation, + }), + ); + if (!patchResult.ok) { + if (patchResult.stop.kind === "pending") return command; + return failCommand(deps, command, patchResult.stop.reason, connectionId); + } + const result = patchResult.value; + + return commitProviderOccurrenceUpdate( + deps, + command, + master, + recurrenceId, + content, + schedule, + providerEventId, + result.providerVersion, + now, + ); +} + +// Commit a provider occurrence override locally: upsert the exception +// carrying the INSTANCE's own provider identity (never the master's — see +// upsertException's providerIdentity param), reproject the master to exclude +// that instant, then project the exception's own occurrence. +async function commitProviderOccurrenceUpdate( + deps: ProviderMutationDeps, + command: CommandRecord, + master: EventRecord, + recurrenceId: DateTime, + content: SyncEventContent, + schedule: EventSchedule, + providerEventId: string, + providerVersion: string, + now: () => Date, +): Promise { + if (command.input.kind !== "update") { + throw new Error( + "commitProviderOccurrenceUpdate requires an update command", + ); + } + const exception = await deps.events.upsertException( + master, + recurrenceId, + { + content, + schedule, + cancelled: false, + providerIdentity: { + providerEventId: providerEventId as ProviderEventId, + providerVersion: providerVersion as ProviderEventVersion, + }, + }, + now(), + ); + await reprojectMaster(deps, command, master, now); + await reprojectOccurrences(deps.occurrences, exception, now); + + const confirmed = await deps.commands.updateOutcome( + command.tenantId, + command.principalId, + command._id, + { + state: "confirmed", + providerEventId: providerEventId as ProviderEventId, + providerVersion: providerVersion as ProviderEventVersion, + }, + command.attemptCount, + ); + return confirmed ?? command; +} + +// Apply a Compass-initiated scope-"this" delete to one occurrence of a +// provider-linked series: resolve the instance, delete IT at the provider +// (Google represents this as cancelling that one instance — the series and +// every other instance are untouched), then cancel the local tombstone. +// Idempotent: an already-cancelled or never-materialized instance converges +// to the same local tombstone without a second provider call, mirroring how +// a whole-event delete treats an already-absent target as success. +export async function executeProviderOccurrenceDelete( + deps: ProviderMutationDeps, + command: CommandRecord, + master: EventRecord, + calendar: ProviderCalendarRecord, + now: () => Date, +): Promise { + if (command.input.kind !== "delete" || command.input.recurrenceId === null) { + throw new Error( + "executeProviderOccurrenceDelete requires a this-scope delete command", + ); + } + if (!master.connectionId || !master.providerEventId) { + throw new Error( + "executeProviderOccurrenceDelete requires a linked series master", + ); + } + if (master.recurrence.kind !== "seriesMaster") { + throw new Error("executeProviderOccurrenceDelete requires a series master"); + } + const { input } = command; + const recurrenceId = input.recurrenceId as DateTime; + const connectionId = master.connectionId; + const seriesProviderEventId = master.providerEventId; + + const tokenResult = await resolveAccessToken(deps.custody, connectionId); + if (!tokenResult.ok) { + if (tokenResult.stop.kind === "pending") return command; + return failCommand(deps, command, tokenResult.stop.reason, connectionId); + } + const { accessToken } = tokenResult; + + const fetchInstanceResult = await runProviderWrite(() => + deps.writer.fetchInstanceAt({ + accessToken, + calendarId: calendar.providerCalendarId, + seriesProviderEventId, + originalStartAt: recurrenceId, + scheduleKind: master.schedule.kind, + }), + ); + if (!fetchInstanceResult.ok) { + if (fetchInstanceResult.stop.kind === "pending") return command; + return failCommand( + deps, + command, + fetchInstanceResult.stop.reason, + connectionId, + ); + } + const instance = + fetchInstanceResult.value?.kind === "event" && + fetchInstanceResult.value.providerEventId !== seriesProviderEventId + ? fetchInstanceResult.value + : null; + + if (instance) { + const deleteResult = await runProviderWrite(() => + deps.writer.deleteEvent({ + accessToken, + calendarId: calendar.providerCalendarId, + providerEventId: instance.providerEventId, + // Unconditional, same rationale as the whole-event delete: cancelling + // one instance is not conditioned on its version. + expectedVersion: null, + invitation: input.invitation, + }), + ); + if (!deleteResult.ok) { + if (deleteResult.stop.kind === "pending") return command; + return failCommand(deps, command, deleteResult.stop.reason, connectionId); + } + } + + const exception = await deps.events.upsertException( + master, + recurrenceId, + { + content: master.content, + schedule: occurrenceScheduleAt(master.schedule, recurrenceId), + cancelled: true, + // `null`, not omitted: this exception is provider-linked (master is), + // and omitting would fall back to the master's own providerEventId — + // colliding the provider_event_identity unique index the master + // already occupies. When the instance is already gone at the provider + // there is no live counterpart to record. + providerIdentity: instance + ? { + providerEventId: instance.providerEventId as ProviderEventId, + providerVersion: instance.providerVersion as ProviderEventVersion, + } + : null, + }, + now(), + ); + await reprojectMaster(deps, command, master, now); + await reprojectOccurrences(deps.occurrences, exception, now); + + const confirmed = await deps.commands.updateOutcome( + command.tenantId, + command.principalId, + command._id, + // No live provider target for this command once the instance is + // cancelled — same shape confirmDeletion uses for a whole-event delete. + { state: "confirmed", providerEventId: null, providerVersion: null }, + command.attemptCount, + ); + return confirmed ?? command; +} + +// Apply a Compass-initiated scope-"thisAndFollowing" delete to a +// provider-linked series: truncate the provider master's rules to end before +// the split (Google removes every instance from that point on), drop the +// local exceptions at/after it, and reproject. A split at the series' own +// first occurrence removes the whole series, so it collapses to the existing +// whole-series provider delete. Content/schedule are NOT part of this write — +// only recurrence changes — so the replay check and patch both hold the +// master's own content/schedule fixed. diff --git a/packages/sync/src/domain/provider-command.recurring-following.db.test.ts b/packages/sync/src/domain/provider-command.recurring-following.db.test.ts new file mode 100644 index 0000000000..8465542508 --- /dev/null +++ b/packages/sync/src/domain/provider-command.recurring-following.db.test.ts @@ -0,0 +1,487 @@ +import { faker } from "@faker-js/faker"; +import { type Document, type Filter } from "mongodb"; +import { + type DateTime, + type EventId, + type TimeZone, +} from "@core/types/domain-primitives"; +import { type SyncCommandInput } from "@core/types/sync/command.contracts"; +import { + type IdempotencyKey, + type PrincipalId, + type ProviderEventId, +} from "@core/types/sync/identity.contracts"; +import { + bindCommandRepos, + COMMAND_NOW, + FakeProviderEventWriter, + newCommandIds, + providerDeleteDeps, + providerMutationDeps, + seedCommandCalendar, + seedLinkedEvent, +} from "@sync/__tests__/helpers/command-scenario"; +import { setupSyncStorage } from "@sync/__tests__/helpers/storage"; +import { truncateRulesBefore } from "@sync/domain/occurrence-projection"; +import { + executeProviderSeriesFollowingDelete, + executeProviderSeriesFollowingUpdate, +} from "@sync/domain/provider-command.series-following"; +import { reprojectOccurrences } from "@sync/domain/reproject"; +import { type ProviderEvent } from "@sync/providers/provider-event.port"; +import { ProviderWriteError } from "@sync/providers/provider-event-writer.port"; +import { SYNC_COLLECTIONS } from "@sync/storage/collections"; +import { type EventRecord } from "@sync/storage/contracts/event.contracts"; +import { type CommandRepository } from "@sync/storage/repositories/command.repository"; +import { type CredentialRepository } from "@sync/storage/repositories/credential.repository"; +import { type DeletionMarkerRepository } from "@sync/storage/repositories/deletion-marker.repository"; +import { type EventRepository } from "@sync/storage/repositories/event.repository"; +import { type EventOccurrenceRepository } from "@sync/storage/repositories/event-occurrence.repository"; +import { type ProviderCalendarRepository } from "@sync/storage/repositories/provider-calendar.repository"; +import { type SyncResourceRepository } from "@sync/storage/repositories/sync-resource.repository"; +import { type SyncMongoService } from "@sync/storage/sync-mongo.service"; +import { beforeEach, describe, expect, it } from "bun:test"; + +const storage = setupSyncStorage(import.meta.url); +const repos = bindCommandRepos(storage); +const objectId = () => faker.database.mongodbObjectId(); +const now = COMMAND_NOW; + +let mongo: SyncMongoService; +let commands: CommandRepository; +let events: EventRepository; +let occurrences: EventOccurrenceRepository; +let resources: SyncResourceRepository; +let calendars: ProviderCalendarRepository; +let markers: DeletionMarkerRepository; +let _credentials: CredentialRepository; + +beforeEach(() => { + mongo = repos.mongo; + commands = repos.commands; + events = repos.events; + occurrences = repos.occurrences; + resources = repos.resources; + calendars = repos.calendars; + markers = repos.markers; + _credentials = repos.credentials; +}); + +describe("provider-linked recurring scopes (this / thisAndFollowing)", () => { + // A weekly series of three occurrences starting 2026-07-14 09:00 Denver: + // 07-14, 07-21, 07-28 (all 15:00Z). Matches the cloud-path test fixtures + // exactly, so results are directly comparable. + const schedule = { + kind: "timed" as const, + start: "2026-07-14T09:00:00-06:00" as DateTime, + end: "2026-07-14T10:00:00-06:00" as DateTime, + timeZone: "America/Denver" as TimeZone, + }; + const weekly3 = ["RRULE:FREQ=WEEKLY;COUNT=3"]; + const SECOND_START = "2026-07-21T09:00:00-06:00"; + const SECOND_START_UTC = "2026-07-21T15:00:00.000Z"; + const content = (title: string) => ({ + title, + description: "", + location: null, + organizer: null, + attendees: [], + conference: null, + }); + const _providerInstance = ( + providerEventId: string, + title: string, + version: string, + instanceSchedule = { + kind: "timed" as const, + start: SECOND_START as DateTime, + end: "2026-07-21T10:00:00-06:00" as DateTime, + timeZone: "America/Denver" as TimeZone, + }, + ): ProviderEvent => ({ + kind: "event", + providerEventId, + providerVersion: version, + providerUpdatedAt: null, + content: content(title), + schedule: instanceSchedule, + busy: true, + // Matches what the real normalizer reports for an event resolved off a + // series via fetchInstanceAt — NOT "single". A prior version of this + // fixture used "single", which papered over a bug where the replay + // short-circuit could never match a real instance read. + recurrence: { + kind: "instance", + seriesProviderId: "g-series-1", + recurrenceId: SECOND_START, + }, + }); + const providerSeries = ( + title: string, + version: string, + rules: readonly string[], + ): ProviderEvent => ({ + kind: "event", + providerEventId: "g-series-1" as ProviderEventId, + providerVersion: version, + providerUpdatedAt: null, + content: content(title), + schedule, + busy: true, + recurrence: { kind: "seriesMaster", rules: [...rules] }, + }); + + const seedMaster = async () => { + const ids = newCommandIds(); + const calendar = await seedCommandCalendar(calendars, ids); + const master = await seedLinkedEvent(events, { + ids, + calendarId: calendar._id, + providerEventId: "g-series-1" as ProviderEventId, + content: content("Old"), + schedule, + recurrence: { kind: "seriesMaster", rules: [...weekly3] }, + now: now(), + }); + await reprojectOccurrences(occurrences, master, now); + return { + tenantId: ids.tenantId, + principalId: ids.principalId, + calendar, + master, + }; + }; + + const occurrenceStartsFor = async (eventId: EventId): Promise => { + const docs = await mongo.db + .collection(SYNC_COLLECTIONS.eventOccurrences) + .find({ eventId }) + .sort({ startAt: 1 }) + .toArray(); + return docs.map((doc) => (doc["startAt"] as Date).toISOString()); + }; + + const otherSeriesMaster = (principalId: PrincipalId, masterId: EventId) => + mongo.db + .collection(SYNC_COLLECTIONS.events) + .find({ + principalId, + "recurrence.kind": "seriesMaster", + _id: { $ne: masterId }, + } as unknown as Filter) + .toArray(); + + const _thisScopeCommand = async ( + master: EventRecord, + kind: "update" | "delete", + title = "Edited", + ) => + ( + await commands.submit({ + tenantId: master.tenantId, + principalId: master.principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId: master._id, + input: + kind === "update" + ? ({ + kind: "update", + invitation: "none", + content: content(title), + schedule: { + kind: "timed", + start: SECOND_START, + end: "2026-07-21T10:00:00-06:00", + timeZone: "America/Denver", + }, + recurrence: { kind: "preserve" }, + scope: "this", + recurrenceId: SECOND_START, + } as unknown as SyncCommandInput) + : ({ + kind: "delete", + invitation: "none", + scope: "this", + recurrenceId: SECOND_START, + } as unknown as SyncCommandInput), + expectedVersion: null, + }) + ).record; + + const followingCommand = async ( + master: EventRecord, + kind: "update" | "delete", + splitAt = SECOND_START, + title = "Split", + ) => + ( + await commands.submit({ + tenantId: master.tenantId, + principalId: master.principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId: master._id, + input: + kind === "update" + ? ({ + kind: "update", + invitation: "none", + content: content(title), + schedule: { + kind: "timed", + start: splitAt, + end: "2026-07-21T10:00:00-06:00", + timeZone: "America/Denver", + }, + recurrence: { + kind: "series", + rules: ["RRULE:FREQ=WEEKLY;COUNT=2"], + }, + scope: "thisAndFollowing", + recurrenceId: splitAt, + } as unknown as SyncCommandInput) + : ({ + kind: "delete", + invitation: "none", + scope: "thisAndFollowing", + recurrenceId: splitAt, + } as unknown as SyncCommandInput), + expectedVersion: null, + }) + ).record; + + const deps = (writer: FakeProviderEventWriter) => + providerMutationDeps({ commands, events, occurrences, resources }, writer); + const deleteDeps = (writer: FakeProviderEventWriter) => + providerDeleteDeps( + { commands, events, occurrences, resources, markers }, + writer, + ); + + describe("executeProviderSeriesFollowingDelete", () => { + it("truncates the provider master and drops following occurrences", async () => { + const { tenantId, principalId, calendar, master } = await seedMaster(); + const command = await followingCommand(master, "delete"); + const writer = new FakeProviderEventWriter(); + writer.fetchEventResult = providerSeries("Old", "etag-1", weekly3); + + const result = await executeProviderSeriesFollowingDelete( + deleteDeps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls[0]?.recurrence.kind).toBe("series"); + // Content/schedule are unchanged — only the rules were patched. + expect(writer.patchCalls[0]?.content.title).toBe("Old"); + const stored = await events.findById(tenantId, principalId, master._id); + expect(stored?.recurrence.kind).toBe("seriesMaster"); + expect(await occurrenceStartsFor(master._id)).toEqual([ + "2026-07-14T15:00:00.000Z", + ]); + }); + + it("confirms without re-patching when the truncation already landed", async () => { + const { calendar, master } = await seedMaster(); + const command = await followingCommand(master, "delete"); + const writer = new FakeProviderEventWriter(); + // Provider already reflects the truncated rules — the exact UNTIL-based + // form truncateRulesBefore itself produces, not just an equivalent + // COUNT-based rule (matchesIntendedEdit compares rule strings, not + // recurrence semantics, so only this form is recognized as a replay). + writer.fetchEventResult = providerSeries( + "Old", + "etag-2", + truncateRulesBefore(weekly3, new Date(SECOND_START)), + ); + + const result = await executeProviderSeriesFollowingDelete( + deleteDeps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(0); + }); + + it("collapses to the whole-series provider delete at the first occurrence", async () => { + const { tenantId, principalId, calendar, master } = await seedMaster(); + const command = await followingCommand( + master, + "delete", + "2026-07-14T09:00:00-06:00", + ); + const writer = new FakeProviderEventWriter(); + + const result = await executeProviderSeriesFollowingDelete( + deleteDeps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + // executeProviderDelete's path was taken: the whole event is gone. + expect(writer.deleteCalls).toHaveLength(1); + expect( + await events.findById(tenantId, principalId, master._id), + ).toBeNull(); + }); + + it("leaves the command pending on a transient patch failure", async () => { + const { calendar, master } = await seedMaster(); + const command = await followingCommand(master, "delete"); + const writer = new FakeProviderEventWriter(); + writer.fetchEventResult = providerSeries("Old", "etag-1", weekly3); + writer.patchError = new ProviderWriteError("transient", "blip"); + + const result = await executeProviderSeriesFollowingDelete( + deleteDeps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("pending"); + }); + }); + + describe("executeProviderSeriesFollowingUpdate", () => { + it("truncates the original and creates a deterministic remainder at the provider", async () => { + const { principalId, calendar, master } = await seedMaster(); + const command = await followingCommand(master, "update"); + const writer = new FakeProviderEventWriter(); + writer.fetchEventResult = providerSeries("Old", "etag-1", weekly3); + writer.createResult = { + providerEventId: "g-remainder-1" as ProviderEventId, + providerVersion: "etag-1", + }; + + const result = await executeProviderSeriesFollowingUpdate( + deps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + // Original truncated to just the pre-split occurrence. + expect(await occurrenceStartsFor(master._id)).toEqual([ + "2026-07-14T15:00:00.000Z", + ]); + // Remainder created at the provider with the deterministic id. + expect(writer.createCalls).toHaveLength(1); + const remainders = await otherSeriesMaster(principalId, master._id); + expect(remainders).toHaveLength(1); + expect(writer.createCalls[0]?.providerEventId).toBe( + String(remainders[0]?.["_id"]), + ); + expect(remainders[0]?.["content"]).toMatchObject({ title: "Split" }); + const remainderId = String(remainders[0]?.["_id"]) as EventId; + expect(await occurrenceStartsFor(remainderId)).toEqual([ + SECOND_START_UTC, + "2026-07-28T15:00:00.000Z", + ]); + }); + + it("upserts a single remainder across two splits at the same point (idempotent retry)", async () => { + // Two distinct commands (fresh idempotency keys), same split point — + // mirrors the cloud path's own convergence test. The deterministic + // remainder id (remainderMasterId) means deps.events.put upserts the + // SAME Mongo document both times, regardless of whether each call's + // own provider-replay check fires — that Mongo-level convergence is + // what this test locks in. + const { principalId, calendar, master } = await seedMaster(); + const first = await followingCommand( + master, + "update", + SECOND_START, + "First", + ); + const writerA = new FakeProviderEventWriter(); + writerA.fetchEventResult = providerSeries("Old", "etag-1", weekly3); + await executeProviderSeriesFollowingUpdate( + deps(writerA), + first, + master, + calendar, + now, + ); + + const second = await followingCommand( + master, + "update", + SECOND_START, + "Second", + ); + const writerB = new FakeProviderEventWriter(); + writerB.fetchEventResult = providerSeries("Old", "etag-1", weekly3); + + await executeProviderSeriesFollowingUpdate( + deps(writerB), + second, + master, + calendar, + now, + ); + + expect(await otherSeriesMaster(principalId, master._id)).toHaveLength(1); + }); + + it("collapses to the provider edit-all at the first occurrence", async () => { + const { tenantId, principalId, calendar, master } = await seedMaster(); + const command = await followingCommand( + master, + "update", + "2026-07-14T09:00:00-06:00", + "Whole", + ); + const writer = new FakeProviderEventWriter(); + writer.fetchEventResult = providerSeries("Old", "etag-1", weekly3); + + const result = await executeProviderSeriesFollowingUpdate( + deps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.createCalls).toHaveLength(0); + const stored = await events.findById(tenantId, principalId, master._id); + expect(stored?.content.title).toBe("Whole"); + expect(await otherSeriesMaster(principalId, master._id)).toHaveLength(0); + }); + + it("leaves the command pending on a transient create failure for the remainder", async () => { + const { calendar, master } = await seedMaster(); + const command = await followingCommand(master, "update"); + const writer = new FakeProviderEventWriter(); + writer.fetchEventResult = providerSeries("Old", "etag-1", weekly3); + writer.createError = new ProviderWriteError("transient", "blip"); + + const result = await executeProviderSeriesFollowingUpdate( + deps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("pending"); + }); + }); +}); + +// WP-02: attendeesEdit "replace" — merge-by-email against freshly fetched +// provider state, organizer guard, replay by email set, and byte-identical +// "preserve"/legacy behavior. diff --git a/packages/sync/src/domain/provider-command.recurring-occurrence.db.test.ts b/packages/sync/src/domain/provider-command.recurring-occurrence.db.test.ts new file mode 100644 index 0000000000..8026dc1452 --- /dev/null +++ b/packages/sync/src/domain/provider-command.recurring-occurrence.db.test.ts @@ -0,0 +1,562 @@ +import { faker } from "@faker-js/faker"; +import { type Document, type Filter } from "mongodb"; +import { + type DateTime, + type EventId, + type TimeZone, +} from "@core/types/domain-primitives"; +import { type SyncCommandInput } from "@core/types/sync/command.contracts"; +import { + type IdempotencyKey, + type PrincipalId, + type ProviderEventId, +} from "@core/types/sync/identity.contracts"; +import { + bindCommandRepos, + COMMAND_NOW, + FakeProviderEventWriter, + newCommandIds, + providerDeleteDeps, + providerMutationDeps, + seedCommandCalendar, + seedLinkedEvent, +} from "@sync/__tests__/helpers/command-scenario"; +import { setupSyncStorage } from "@sync/__tests__/helpers/storage"; +import { + executeProviderOccurrenceDelete, + executeProviderOccurrenceUpdate, +} from "@sync/domain/provider-command.occurrence"; +import { reprojectOccurrences } from "@sync/domain/reproject"; +import { type ProviderEvent } from "@sync/providers/provider-event.port"; +import { ProviderWriteError } from "@sync/providers/provider-event-writer.port"; +import { SYNC_COLLECTIONS } from "@sync/storage/collections"; +import { type EventRecord } from "@sync/storage/contracts/event.contracts"; +import { type CommandRepository } from "@sync/storage/repositories/command.repository"; +import { type CredentialRepository } from "@sync/storage/repositories/credential.repository"; +import { type DeletionMarkerRepository } from "@sync/storage/repositories/deletion-marker.repository"; +import { type EventRepository } from "@sync/storage/repositories/event.repository"; +import { type EventOccurrenceRepository } from "@sync/storage/repositories/event-occurrence.repository"; +import { type ProviderCalendarRepository } from "@sync/storage/repositories/provider-calendar.repository"; +import { type SyncResourceRepository } from "@sync/storage/repositories/sync-resource.repository"; +import { type SyncMongoService } from "@sync/storage/sync-mongo.service"; +import { beforeEach, describe, expect, it } from "bun:test"; + +const storage = setupSyncStorage(import.meta.url); +const repos = bindCommandRepos(storage); +const objectId = () => faker.database.mongodbObjectId(); +const now = COMMAND_NOW; + +let mongo: SyncMongoService; +let commands: CommandRepository; +let events: EventRepository; +let occurrences: EventOccurrenceRepository; +let resources: SyncResourceRepository; +let calendars: ProviderCalendarRepository; +let markers: DeletionMarkerRepository; +let _credentials: CredentialRepository; + +beforeEach(() => { + mongo = repos.mongo; + commands = repos.commands; + events = repos.events; + occurrences = repos.occurrences; + resources = repos.resources; + calendars = repos.calendars; + markers = repos.markers; + _credentials = repos.credentials; +}); + +describe("provider-linked recurring scopes (this / thisAndFollowing)", () => { + // A weekly series of three occurrences starting 2026-07-14 09:00 Denver: + // 07-14, 07-21, 07-28 (all 15:00Z). Matches the cloud-path test fixtures + // exactly, so results are directly comparable. + const schedule = { + kind: "timed" as const, + start: "2026-07-14T09:00:00-06:00" as DateTime, + end: "2026-07-14T10:00:00-06:00" as DateTime, + timeZone: "America/Denver" as TimeZone, + }; + const weekly3 = ["RRULE:FREQ=WEEKLY;COUNT=3"]; + const SECOND_START = "2026-07-21T09:00:00-06:00"; + const SECOND_START_UTC = "2026-07-21T15:00:00.000Z"; + const content = (title: string) => ({ + title, + description: "", + location: null, + organizer: null, + attendees: [], + conference: null, + }); + const providerInstance = ( + providerEventId: string, + title: string, + version: string, + instanceSchedule = { + kind: "timed" as const, + start: SECOND_START as DateTime, + end: "2026-07-21T10:00:00-06:00" as DateTime, + timeZone: "America/Denver" as TimeZone, + }, + ): ProviderEvent => ({ + kind: "event", + providerEventId, + providerVersion: version, + providerUpdatedAt: null, + content: content(title), + schedule: instanceSchedule, + busy: true, + // Matches what the real normalizer reports for an event resolved off a + // series via fetchInstanceAt — NOT "single". A prior version of this + // fixture used "single", which papered over a bug where the replay + // short-circuit could never match a real instance read. + recurrence: { + kind: "instance", + seriesProviderId: "g-series-1", + recurrenceId: SECOND_START, + }, + }); + const providerSeries = ( + title: string, + version: string, + rules: readonly string[], + ): ProviderEvent => ({ + kind: "event", + providerEventId: "g-series-1" as ProviderEventId, + providerVersion: version, + providerUpdatedAt: null, + content: content(title), + schedule, + busy: true, + recurrence: { kind: "seriesMaster", rules: [...rules] }, + }); + + const seedMaster = async () => { + const ids = newCommandIds(); + const calendar = await seedCommandCalendar(calendars, ids); + const master = await seedLinkedEvent(events, { + ids, + calendarId: calendar._id, + providerEventId: "g-series-1" as ProviderEventId, + content: content("Old"), + schedule, + recurrence: { kind: "seriesMaster", rules: [...weekly3] }, + now: now(), + }); + await reprojectOccurrences(occurrences, master, now); + return { + tenantId: ids.tenantId, + principalId: ids.principalId, + calendar, + master, + }; + }; + + const occurrenceStartsFor = async (eventId: EventId): Promise => { + const docs = await mongo.db + .collection(SYNC_COLLECTIONS.eventOccurrences) + .find({ eventId }) + .sort({ startAt: 1 }) + .toArray(); + return docs.map((doc) => (doc["startAt"] as Date).toISOString()); + }; + + const _otherSeriesMaster = (principalId: PrincipalId, masterId: EventId) => + mongo.db + .collection(SYNC_COLLECTIONS.events) + .find({ + principalId, + "recurrence.kind": "seriesMaster", + _id: { $ne: masterId }, + } as unknown as Filter) + .toArray(); + + const thisScopeCommand = async ( + master: EventRecord, + kind: "update" | "delete", + title = "Edited", + ) => + ( + await commands.submit({ + tenantId: master.tenantId, + principalId: master.principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId: master._id, + input: + kind === "update" + ? ({ + kind: "update", + invitation: "none", + content: content(title), + schedule: { + kind: "timed", + start: SECOND_START, + end: "2026-07-21T10:00:00-06:00", + timeZone: "America/Denver", + }, + recurrence: { kind: "preserve" }, + scope: "this", + recurrenceId: SECOND_START, + } as unknown as SyncCommandInput) + : ({ + kind: "delete", + invitation: "none", + scope: "this", + recurrenceId: SECOND_START, + } as unknown as SyncCommandInput), + expectedVersion: null, + }) + ).record; + + const _followingCommand = async ( + master: EventRecord, + kind: "update" | "delete", + splitAt = SECOND_START, + title = "Split", + ) => + ( + await commands.submit({ + tenantId: master.tenantId, + principalId: master.principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId: master._id, + input: + kind === "update" + ? ({ + kind: "update", + invitation: "none", + content: content(title), + schedule: { + kind: "timed", + start: splitAt, + end: "2026-07-21T10:00:00-06:00", + timeZone: "America/Denver", + }, + recurrence: { + kind: "series", + rules: ["RRULE:FREQ=WEEKLY;COUNT=2"], + }, + scope: "thisAndFollowing", + recurrenceId: splitAt, + } as unknown as SyncCommandInput) + : ({ + kind: "delete", + invitation: "none", + scope: "thisAndFollowing", + recurrenceId: splitAt, + } as unknown as SyncCommandInput), + expectedVersion: null, + }) + ).record; + + const deps = (writer: FakeProviderEventWriter) => + providerMutationDeps({ commands, events, occurrences, resources }, writer); + const deleteDeps = (writer: FakeProviderEventWriter) => + providerDeleteDeps( + { commands, events, occurrences, resources, markers }, + writer, + ); + + describe("executeProviderOccurrenceUpdate", () => { + it("resolves the instance, patches IT (not the master), and stores its own provider identity", async () => { + const { tenantId, principalId, calendar, master } = await seedMaster(); + const command = await thisScopeCommand(master, "update", "Moved"); + const writer = new FakeProviderEventWriter(); + writer.fetchInstanceResult = providerInstance( + "g-inst-1", + "Old", + "etag-1", + ); + + const result = await executeProviderOccurrenceUpdate( + deps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.fetchInstanceCalls[0]).toMatchObject({ + seriesProviderEventId: "g-series-1", + originalStartAt: SECOND_START, + scheduleKind: "timed", + }); + // Patched the INSTANCE's own resolved id, never the master's. + expect(writer.patchCalls[0]?.providerEventId).toBe("g-inst-1"); + // "instance", not "single" — Google rejects a recurrence key at all on + // an event resolved off a series via fetchInstanceAt. + expect(writer.patchCalls[0]?.recurrence).toEqual({ kind: "instance" }); + + const exceptions = await events.findSeriesExceptions( + tenantId, + principalId, + master._id, + ); + expect(exceptions).toHaveLength(1); + // The exception carries the INSTANCE's own provider identity, not the + // master's — sharing the master's would collide the unique + // provider_event_identity index. + expect(exceptions[0]?.providerEventId).toBe( + "g-inst-1" as ProviderEventId, + ); + expect(exceptions[0]?.content.title).toBe("Moved"); + // The master no longer projects the overridden instant. + expect(await occurrenceStartsFor(master._id)).not.toContain( + SECOND_START_UTC, + ); + }); + + it("confirms without re-patching when the edit already landed (replay)", async () => { + const { calendar, master } = await seedMaster(); + const command = await thisScopeCommand(master, "update", "Moved"); + const writer = new FakeProviderEventWriter(); + // The instance already carries this command's intended content. + writer.fetchInstanceResult = providerInstance( + "g-inst-1", + "Moved", + "etag-2", + ); + + const result = await executeProviderOccurrenceUpdate( + deps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(0); + }); + + it("fails without writing when no instance exists at that instant", async () => { + const { calendar, master } = await seedMaster(); + const command = await thisScopeCommand(master, "update"); + const writer = new FakeProviderEventWriter(); + writer.fetchInstanceResult = null; + + const result = await executeProviderOccurrenceUpdate( + deps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("failed"); + expect( + result.outcome.state === "failed" && result.outcome.failureReason, + ).toBe("permanentProviderError"); + expect(writer.patchCalls).toHaveLength(0); + }); + + it("leaves the command pending on a transient patch failure", async () => { + const { calendar, master } = await seedMaster(); + const command = await thisScopeCommand(master, "update"); + const writer = new FakeProviderEventWriter(); + writer.fetchInstanceResult = providerInstance( + "g-inst-1", + "Old", + "etag-1", + ); + writer.patchError = new ProviderWriteError("transient", "blip"); + + const result = await executeProviderOccurrenceUpdate( + deps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("pending"); + }); + }); + + describe("executeProviderOccurrenceDelete", () => { + it("deletes the resolved instance at the provider and tombstones it locally", async () => { + const { tenantId, principalId, calendar, master } = await seedMaster(); + const command = await thisScopeCommand(master, "delete"); + const writer = new FakeProviderEventWriter(); + writer.fetchInstanceResult = providerInstance( + "g-inst-1", + "Old", + "etag-1", + ); + + const result = await executeProviderOccurrenceDelete( + deleteDeps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.deleteCalls[0]?.providerEventId).toBe("g-inst-1"); + const exceptions = await events.findSeriesExceptions( + tenantId, + principalId, + master._id, + ); + expect(exceptions).toHaveLength(1); + expect( + exceptions[0]?.recurrence.kind === "exception" && + exceptions[0]?.recurrence.cancelled, + ).toBe(true); + // The master no longer projects the cancelled instant; the whole + // series and every OTHER instance are untouched. + expect(await occurrenceStartsFor(master._id)).toEqual([ + "2026-07-14T15:00:00.000Z", + "2026-07-28T15:00:00.000Z", + ]); + }); + + it("converges without a second provider call when the instance is already gone", async () => { + const { tenantId, principalId, calendar, master } = await seedMaster(); + const command = await thisScopeCommand(master, "delete"); + const writer = new FakeProviderEventWriter(); + writer.fetchInstanceResult = null; + + const result = await executeProviderOccurrenceDelete( + deleteDeps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.deleteCalls).toHaveLength(0); + const exceptions = await events.findSeriesExceptions( + tenantId, + principalId, + master._id, + ); + expect(exceptions).toHaveLength(1); + // Regression lock: the tombstone must NOT mirror the master's own + // providerEventId (master.providerEventId is non-null here, since this + // is a provider-linked series) — doing so would collide the + // provider_event_identity unique index the master document already + // occupies. There is no live provider counterpart, so it's null. + expect(exceptions[0]?.providerEventId).toBeNull(); + }); + + it("leaves the command pending on a transient delete failure", async () => { + const { calendar, master } = await seedMaster(); + const command = await thisScopeCommand(master, "delete"); + const writer = new FakeProviderEventWriter(); + writer.fetchInstanceResult = providerInstance( + "g-inst-1", + "Old", + "etag-1", + ); + writer.deleteError = new ProviderWriteError("transient", "blip"); + + const result = await executeProviderOccurrenceDelete( + deleteDeps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("pending"); + }); + + it("fails without tombstoning when the provider declines the delete (unsupportedCapability)", async () => { + // Google 400s a well-formed instance delete for special events (e.g. a + // contact-linked birthday occurrence). The event still exists at the + // provider, so hiding it locally would desync until the next pull + // resurrected it — the command fails honestly instead. + const { tenantId, principalId, calendar, master } = await seedMaster(); + const command = await thisScopeCommand(master, "delete"); + const writer = new FakeProviderEventWriter(); + writer.fetchInstanceResult = providerInstance( + "g-inst-1", + "Old", + "etag-1", + ); + writer.deleteError = new ProviderWriteError( + "unsupportedCapability", + "declined", + ); + + const result = await executeProviderOccurrenceDelete( + deleteDeps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("failed"); + expect( + result.outcome.state === "failed" && result.outcome.failureReason, + ).toBe("unsupportedCapability"); + // No local trace of the refused delete: no cancelled exception, and the + // occurrence still projects. + const exceptions = await events.findSeriesExceptions( + tenantId, + principalId, + master._id, + ); + expect(exceptions).toHaveLength(0); + expect(await occurrenceStartsFor(master._id)).toContain(SECOND_START_UTC); + }); + + it("still deletes when the resolved instance is identity-only (unreadable content)", async () => { + const { tenantId, principalId, calendar, master } = await seedMaster(); + const command = await thisScopeCommand(master, "delete"); + const writer = new FakeProviderEventWriter(); + writer.fetchInstanceResult = { + ...providerInstance("g-inst-unreadable", "", "etag-1"), + content: content(""), + }; + + const result = await executeProviderOccurrenceDelete( + deleteDeps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.deleteCalls[0]?.providerEventId).toBe("g-inst-unreadable"); + const exceptions = await events.findSeriesExceptions( + tenantId, + principalId, + master._id, + ); + expect(exceptions[0]?.providerEventId).toBe( + "g-inst-unreadable" as ProviderEventId, + ); + }); + + it("does not delete the series master when lookup returns the master's id", async () => { + const { tenantId, principalId, calendar, master } = await seedMaster(); + const command = await thisScopeCommand(master, "delete"); + const writer = new FakeProviderEventWriter(); + writer.fetchInstanceResult = providerSeries("Old", "etag-1", weekly3); + + const result = await executeProviderOccurrenceDelete( + deleteDeps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.deleteCalls).toHaveLength(0); + const exceptions = await events.findSeriesExceptions( + tenantId, + principalId, + master._id, + ); + expect(exceptions).toHaveLength(1); + expect(exceptions[0]?.providerEventId).toBeNull(); + }); + }); +}); diff --git a/packages/sync/src/domain/provider-command.rsvp.db.test.ts b/packages/sync/src/domain/provider-command.rsvp.db.test.ts new file mode 100644 index 0000000000..6a98b8afb3 --- /dev/null +++ b/packages/sync/src/domain/provider-command.rsvp.db.test.ts @@ -0,0 +1,458 @@ +import { faker } from "@faker-js/faker"; +import { type DateTime, type TimeZone } from "@core/types/domain-primitives"; +import { type Attendee } from "@core/types/event-attendance.contracts"; +import { type EventColorSlot } from "@core/types/event-color.contracts"; +import { type SyncCommandInput } from "@core/types/sync/command.contracts"; +import { type ProviderEventVersion } from "@core/types/sync/event.contracts"; +import { + type IdempotencyKey, + type ProviderEventId, +} from "@core/types/sync/identity.contracts"; +import { + bindCommandRepos, + COMMAND_NOW, + FakeProviderEventWriter, + newCommandIds, + seedCommandCalendar, + seedLinkedEvent, + tokenSource, +} from "@sync/__tests__/helpers/command-scenario"; +import { setupSyncStorage } from "@sync/__tests__/helpers/storage"; +import { type ProviderConnectionLookup } from "@sync/domain/provider-command.deps"; +import { executeProviderRsvp } from "@sync/domain/provider-command.rsvp"; +import { type ProviderEvent } from "@sync/providers/provider-event.port"; +import { + type ProviderEventWriter, + ProviderWriteError, +} from "@sync/providers/provider-event-writer.port"; +import { findSafetyCanaryHit } from "@sync/safety/safety-canary"; +import { type EventRecord } from "@sync/storage/contracts/event.contracts"; +import { type CommandRepository } from "@sync/storage/repositories/command.repository"; +import { type CredentialRepository } from "@sync/storage/repositories/credential.repository"; +import { type DeletionMarkerRepository } from "@sync/storage/repositories/deletion-marker.repository"; +import { type EventRepository } from "@sync/storage/repositories/event.repository"; +import { type EventOccurrenceRepository } from "@sync/storage/repositories/event-occurrence.repository"; +import { type ProviderCalendarRepository } from "@sync/storage/repositories/provider-calendar.repository"; +import { type SyncResourceRepository } from "@sync/storage/repositories/sync-resource.repository"; +import { type SyncMongoService } from "@sync/storage/sync-mongo.service"; +import { beforeEach, describe, expect, it } from "bun:test"; + +const storage = setupSyncStorage(import.meta.url); +const repos = bindCommandRepos(storage); +const objectId = () => faker.database.mongodbObjectId(); +const now = COMMAND_NOW; + +let _mongo: SyncMongoService; +let commands: CommandRepository; +let events: EventRepository; +let occurrences: EventOccurrenceRepository; +let resources: SyncResourceRepository; +let calendars: ProviderCalendarRepository; +let _markers: DeletionMarkerRepository; +let _credentials: CredentialRepository; + +beforeEach(() => { + _mongo = repos.mongo; + commands = repos.commands; + events = repos.events; + occurrences = repos.occurrences; + resources = repos.resources; + calendars = repos.calendars; + _markers = repos.markers; + _credentials = repos.credentials; +}); + +describe("executeProviderRsvp", () => { + const SELF = "self@example.com"; + + const schedule = { + kind: "timed" as const, + start: "2026-07-14T09:00:00-06:00" as DateTime, + end: "2026-07-14T10:00:00-06:00" as DateTime, + timeZone: "America/Denver" as TimeZone, + }; + const _weekly3 = ["RRULE:FREQ=WEEKLY;COUNT=3"]; + const _SECOND_START_UTC = "2026-07-21T15:00:00.000Z"; + + const attendee = ( + email: string, + responseStatus: Attendee["responseStatus"] = "needsAction", + displayName: string | null = null, + ): Attendee => ({ email, displayName, responseStatus }); + + const contentWith = ( + title: string, + opts: { + organizer?: { email: string; displayName: string | null } | null; + attendees?: Attendee[]; + color?: EventColorSlot; + } = {}, + ) => ({ + title, + description: "", + location: null, + organizer: opts.organizer ?? null, + attendees: opts.attendees ?? [], + conference: null, + ...(opts.color ? { color: opts.color } : {}), + }); + + const connectionsWith = (email: string | null): ProviderConnectionLookup => ({ + findById: async () => ({ account: { email }, provider: "google" }), + }); + const missingConnection: ProviderConnectionLookup = { + findById: async () => null, + }; + + const deps = ( + writer: ProviderEventWriter, + connections: ProviderConnectionLookup, + ) => ({ + commands, + events, + occurrences, + resources, + connections, + writer, + custody: tokenSource(), + }); + + const seedLinked = async ( + opts: { + organizer?: { email: string; displayName: string | null } | null; + storedAttendees?: Attendee[]; + recurrence?: { kind: "seriesMaster"; rules: string[] }; + } = {}, + ) => { + const ids = newCommandIds(); + const calendar = await seedCommandCalendar(calendars, ids); + const event = await seedLinkedEvent(events, { + ids, + calendarId: calendar._id, + content: contentWith("Invited", { + organizer: opts.organizer ?? { + email: "organizer@example.com", + displayName: null, + }, + attendees: opts.storedAttendees ?? [ + attendee("organizer@example.com", "accepted"), + attendee(SELF, "accepted"), + ], + }), + schedule, + recurrence: opts.recurrence ?? { kind: "single" }, + now: now(), + }); + return { + tenantId: ids.tenantId, + principalId: ids.principalId, + calendar, + event, + }; + }; + + const rsvpCommand = async ( + event: EventRecord, + opts: { + responseStatus?: "accepted" | "declined" | "tentative"; + scope?: string; + recurrenceId?: string | null; + } = {}, + ) => + ( + await commands.submit({ + tenantId: event.tenantId, + principalId: event.principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId: event._id, + input: { + kind: "rsvp", + responseStatus: opts.responseStatus ?? "declined", + scope: opts.scope ?? "all", + recurrenceId: opts.recurrenceId ?? null, + } as unknown as SyncCommandInput, + expectedVersion: null, + }) + ).record; + + const providerSingle = ( + version: string, + attendees: Attendee[], + opts: { color?: EventColorSlot } = {}, + ): ProviderEvent => ({ + kind: "event", + providerEventId: "g-evt-1" as ProviderEventId, + providerVersion: version, + providerUpdatedAt: null, + content: contentWith("Invited", { + organizer: { email: "organizer@example.com", displayName: null }, + attendees, + color: opts.color, + }) as ProviderEvent["content"], + schedule, + busy: true, + recurrence: { kind: "single" }, + }); + + it("rewrites only the self entry (case-insensitive) and patches the full list with sendUpdates none", async () => { + // Acceptance "Normal": accepted → declined on a single event. The + // account email is cased differently than the provider's entry, and the + // provider list carries fresher sibling RSVPs than the stored copy. + const { tenantId, principalId, calendar, event } = await seedLinked(); + const command = await rsvpCommand(event, { responseStatus: "declined" }); + const writer = new FakeProviderEventWriter(); + const fetchedList = [ + attendee("organizer@example.com", "accepted", "Org"), + attendee("Self@Example.COM", "accepted"), + attendee("other@example.com", "tentative", "Oth"), + ]; + writer.fetched = providerSingle("etag-1", fetchedList, { color: "coral" }); + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(1); + const patch = writer.patchCalls[0]; + // The full merged list rides the attendee body emission: only the self + // entry's responseStatus changed; every other entry — and the self + // entry's own email casing and displayName — is byte-identical to the + // freshly fetched provider state. + expect(patch!.attendees).toEqual([ + attendee("organizer@example.com", "accepted", "Org"), + attendee("Self@Example.COM", "declined"), + attendee("other@example.com", "tentative", "Oth"), + ]); + expect(patch!.attendees?.[0]).toEqual(fetchedList[0] as Attendee); + expect(patch!.attendees?.[2]).toEqual(fetchedList[2] as Attendee); + // Never emails the guest list, and never conditions on a version: a + // concurrent sibling RSVP must not block this one. + expect(patch!.invitation).toBe("none"); + expect(patch!.expectedVersion).toBeNull(); + expect(patch!.providerEventId).toBe("g-evt-1"); + // The echoed body carries the fetched content minus color/colorHex, so + // the patch cannot touch Google's color or label state. + expect(patch!.content.title).toBe("Invited"); + expect(patch!.content).not.toHaveProperty("color"); + expect(patch!.content).not.toHaveProperty("colorHex"); + + // The answer lands on the stored record before the next Google + // round-trip. + const stored = await events.findById(tenantId, principalId, event._id); + expect(stored?.content.attendees).toEqual([ + attendee("organizer@example.com", "accepted", "Org"), + attendee("Self@Example.COM", "declined"), + attendee("other@example.com", "tentative", "Oth"), + ]); + expect(stored?.providerVersion).toBe("etag-2" as ProviderEventVersion); + expect( + result.outcome.state === "confirmed" && result.outcome.providerVersion, + ).toBe("etag-2" as ProviderEventVersion); + }); + + it("confirms a replay without a second write when the provider already holds the answer", async () => { + const { calendar, event } = await seedLinked(); + const command = await rsvpCommand(event, { responseStatus: "tentative" }); + const writer = new FakeProviderEventWriter(); + // The prior attempt landed (or the user answered from another client). + writer.fetched = providerSingle("etag-7", [ + attendee("organizer@example.com", "accepted"), + attendee(SELF, "tentative"), + ]); + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(0); + expect( + result.outcome.state === "confirmed" && result.outcome.providerVersion, + ).toBe("etag-7" as ProviderEventVersion); + }); + + it("allows the organizer to RSVP their own event", async () => { + // Finish line 4: no organizer guard here — Google lists the organizer as + // an attendee of their own event, and answering it is theirs to do. + const { calendar, event } = await seedLinked({ + organizer: { email: SELF, displayName: null }, + storedAttendees: [ + attendee(SELF, "accepted"), + attendee("guest@example.com", "needsAction"), + ], + }); + const command = await rsvpCommand(event, { responseStatus: "tentative" }); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerSingle("etag-1", [ + attendee(SELF, "accepted"), + attendee("guest@example.com", "needsAction"), + ]); + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls[0]!.attendees).toEqual([ + attendee(SELF, "tentative"), + attendee("guest@example.com", "needsAction"), + ]); + }); + + it("fails typed when the account is not in the stored guest list, with no provider call", async () => { + // Acceptance "Policy": self not an attendee → unsupportedCapability + // BEFORE any provider call, and no attendee JSON anywhere the route + // logs from. + const { calendar, event } = await seedLinked({ + storedAttendees: [ + attendee("organizer@example.com", "accepted"), + attendee("someone-else@example.com", "needsAction"), + ], + }); + const command = await rsvpCommand(event); + const writer = new FakeProviderEventWriter(); + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + expect(writer.fetchCalls).toHaveLength(0); + expect(writer.patchCalls).toHaveLength(0); + expect(findSafetyCanaryHit(result.outcome)).toBeNull(); + expect( + findSafetyCanaryHit( + `Command ${result._id} (${result.input.kind} ${result.eventId}) failed: ${ + result.outcome.state === "failed" && result.outcome.failureReason + }`, + ), + ).toBeNull(); + }); + + it("fails closed when the connection cannot be resolved", async () => { + const { calendar, event } = await seedLinked(); + const command = await rsvpCommand(event); + const writer = new FakeProviderEventWriter(); + + const result = await executeProviderRsvp( + deps(writer, missingConnection), + command, + event, + calendar, + now, + ); + + expect(result.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + expect(writer.fetchCalls).toHaveLength(0); + }); + + it("fails closed when the connection has no account email", async () => { + const { calendar, event } = await seedLinked(); + const command = await rsvpCommand(event); + const writer = new FakeProviderEventWriter(); + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(null)), + command, + event, + calendar, + now, + ); + + expect(result.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + expect(writer.fetchCalls).toHaveLength(0); + }); + + it("fails typed when the provider no longer lists the account, without writing", async () => { + // The stored list still has SELF, but the fetched state does not + // (uninvited provider-side since the last pull): same typed refusal, + // discovered after the fetch — never a write. + const { calendar, event } = await seedLinked(); + const command = await rsvpCommand(event); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerSingle("etag-3", [ + attendee("organizer@example.com", "accepted"), + ]); + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + expect(writer.fetchCalls).toHaveLength(1); + expect(writer.patchCalls).toHaveLength(0); + }); + + it("leaves the command pending on a transient fetch failure, with no patch", async () => { + // Acceptance "Tool failure": fetch 5xx → the command stays retryable. + const { calendar, event } = await seedLinked(); + const command = await rsvpCommand(event); + const writer = new FakeProviderEventWriter(); + writer.fetchError = new ProviderWriteError("transient", "blip"); + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("pending"); + expect(writer.patchCalls).toHaveLength(0); + }); + + it("fails permanently when nothing live exists to answer", async () => { + const { calendar, event } = await seedLinked(); + const command = await rsvpCommand(event); + const writer = new FakeProviderEventWriter(); + writer.fetched = null; + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome).toEqual({ + state: "failed", + failureReason: "permanentProviderError", + }); + expect(writer.patchCalls).toHaveLength(0); + }); +}); diff --git a/packages/sync/src/domain/provider-command.rsvp.scopes.db.test.ts b/packages/sync/src/domain/provider-command.rsvp.scopes.db.test.ts new file mode 100644 index 0000000000..1b0300e7e3 --- /dev/null +++ b/packages/sync/src/domain/provider-command.rsvp.scopes.db.test.ts @@ -0,0 +1,439 @@ +import { faker } from "@faker-js/faker"; +import { type DateTime, type TimeZone } from "@core/types/domain-primitives"; +import { type Attendee } from "@core/types/event-attendance.contracts"; +import { type EventColorSlot } from "@core/types/event-color.contracts"; +import { type SyncCommandInput } from "@core/types/sync/command.contracts"; +import { type ProviderEventVersion } from "@core/types/sync/event.contracts"; +import { + type IdempotencyKey, + type ProviderEventId, +} from "@core/types/sync/identity.contracts"; +import { + bindCommandRepos, + COMMAND_NOW, + FakeProviderEventWriter, + newCommandIds, + seedCommandCalendar, + seedLinkedEvent, + tokenSource, +} from "@sync/__tests__/helpers/command-scenario"; +import { setupSyncStorage } from "@sync/__tests__/helpers/storage"; +import { type ProviderConnectionLookup } from "@sync/domain/provider-command.deps"; +import { executeProviderRsvp } from "@sync/domain/provider-command.rsvp"; +import { type ProviderEvent } from "@sync/providers/provider-event.port"; +import { type ProviderEventWriter } from "@sync/providers/provider-event-writer.port"; +import { SYNC_COLLECTIONS } from "@sync/storage/collections"; +import { type EventRecord } from "@sync/storage/contracts/event.contracts"; +import { type CommandRepository } from "@sync/storage/repositories/command.repository"; +import { type CredentialRepository } from "@sync/storage/repositories/credential.repository"; +import { type DeletionMarkerRepository } from "@sync/storage/repositories/deletion-marker.repository"; +import { type EventRepository } from "@sync/storage/repositories/event.repository"; +import { type EventOccurrenceRepository } from "@sync/storage/repositories/event-occurrence.repository"; +import { type ProviderCalendarRepository } from "@sync/storage/repositories/provider-calendar.repository"; +import { type SyncResourceRepository } from "@sync/storage/repositories/sync-resource.repository"; +import { type SyncMongoService } from "@sync/storage/sync-mongo.service"; +import { beforeEach, describe, expect, it } from "bun:test"; + +const storage = setupSyncStorage(import.meta.url); +const repos = bindCommandRepos(storage); +const objectId = () => faker.database.mongodbObjectId(); +const now = COMMAND_NOW; + +let mongo: SyncMongoService; +let commands: CommandRepository; +let events: EventRepository; +let occurrences: EventOccurrenceRepository; +let resources: SyncResourceRepository; +let calendars: ProviderCalendarRepository; +let _markers: DeletionMarkerRepository; +let _credentials: CredentialRepository; + +beforeEach(() => { + mongo = repos.mongo; + commands = repos.commands; + events = repos.events; + occurrences = repos.occurrences; + resources = repos.resources; + calendars = repos.calendars; + _markers = repos.markers; + _credentials = repos.credentials; +}); + +describe("executeProviderRsvp", () => { + const SELF = "self@example.com"; + + const schedule = { + kind: "timed" as const, + start: "2026-07-14T09:00:00-06:00" as DateTime, + end: "2026-07-14T10:00:00-06:00" as DateTime, + timeZone: "America/Denver" as TimeZone, + }; + const weekly3 = ["RRULE:FREQ=WEEKLY;COUNT=3"]; + const SECOND_START_UTC = "2026-07-21T15:00:00.000Z"; + + const attendee = ( + email: string, + responseStatus: Attendee["responseStatus"] = "needsAction", + displayName: string | null = null, + ): Attendee => ({ email, displayName, responseStatus }); + + const contentWith = ( + title: string, + opts: { + organizer?: { email: string; displayName: string | null } | null; + attendees?: Attendee[]; + color?: EventColorSlot; + } = {}, + ) => ({ + title, + description: "", + location: null, + organizer: opts.organizer ?? null, + attendees: opts.attendees ?? [], + conference: null, + ...(opts.color ? { color: opts.color } : {}), + }); + + const connectionsWith = (email: string | null): ProviderConnectionLookup => ({ + findById: async () => ({ account: { email }, provider: "google" }), + }); + const _missingConnection: ProviderConnectionLookup = { + findById: async () => null, + }; + + const deps = ( + writer: ProviderEventWriter, + connections: ProviderConnectionLookup, + ) => ({ + commands, + events, + occurrences, + resources, + connections, + writer, + custody: tokenSource(), + }); + + const seedLinked = async ( + opts: { + organizer?: { email: string; displayName: string | null } | null; + storedAttendees?: Attendee[]; + recurrence?: { kind: "seriesMaster"; rules: string[] }; + } = {}, + ) => { + const ids = newCommandIds(); + const calendar = await seedCommandCalendar(calendars, ids); + const event = await seedLinkedEvent(events, { + ids, + calendarId: calendar._id, + content: contentWith("Invited", { + organizer: opts.organizer ?? { + email: "organizer@example.com", + displayName: null, + }, + attendees: opts.storedAttendees ?? [ + attendee("organizer@example.com", "accepted"), + attendee(SELF, "accepted"), + ], + }), + schedule, + recurrence: opts.recurrence ?? { kind: "single" }, + now: now(), + }); + return { + tenantId: ids.tenantId, + principalId: ids.principalId, + calendar, + event, + }; + }; + + const rsvpCommand = async ( + event: EventRecord, + opts: { + responseStatus?: "accepted" | "declined" | "tentative"; + scope?: string; + recurrenceId?: string | null; + } = {}, + ) => + ( + await commands.submit({ + tenantId: event.tenantId, + principalId: event.principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId: event._id, + input: { + kind: "rsvp", + responseStatus: opts.responseStatus ?? "declined", + scope: opts.scope ?? "all", + recurrenceId: opts.recurrenceId ?? null, + } as unknown as SyncCommandInput, + expectedVersion: null, + }) + ).record; + + const providerSingle = ( + version: string, + attendees: Attendee[], + opts: { color?: EventColorSlot } = {}, + ): ProviderEvent => ({ + kind: "event", + providerEventId: "g-evt-1" as ProviderEventId, + providerVersion: version, + providerUpdatedAt: null, + content: contentWith("Invited", { + organizer: { email: "organizer@example.com", displayName: null }, + attendees, + color: opts.color, + }) as ProviderEvent["content"], + schedule, + busy: true, + recurrence: { kind: "single" }, + }); + + const providerInstance = (attendees: Attendee[]): ProviderEvent => ({ + kind: "event", + providerEventId: "g-inst-1" as ProviderEventId, + providerVersion: "etag-inst-1", + providerUpdatedAt: null, + content: contentWith("Invited", { + organizer: { email: "organizer@example.com", displayName: null }, + attendees, + }) as ProviderEvent["content"], + schedule: { + kind: "timed", + start: "2026-07-21T09:00:00-06:00" as DateTime, + end: "2026-07-21T10:00:00-06:00" as DateTime, + timeZone: "America/Denver" as TimeZone, + }, + busy: true, + recurrence: { + kind: "instance", + seriesProviderId: "g-evt-1", + recurrenceId: SECOND_START_UTC, + }, + }); + + it("patches the resolved Google instance on a scope-this rsvp, leaving the master untouched", async () => { + // Acceptance "Normal": declined on ONE occurrence leaves the master and + // sibling occurrences untouched. The instance id comes from the writer's + // own fetchInstanceAt resolution — never hand-built. + const { tenantId, principalId, calendar, event } = await seedLinked({ + recurrence: { kind: "seriesMaster", rules: weekly3 }, + }); + const command = await rsvpCommand(event, { + responseStatus: "declined", + scope: "this", + recurrenceId: SECOND_START_UTC, + }); + const writer = new FakeProviderEventWriter(); + writer.fetchInstanceResult = providerInstance([ + attendee("organizer@example.com", "accepted"), + attendee(SELF, "accepted"), + ]); + writer.patchResult = { + providerEventId: "g-inst-1" as ProviderEventId, + providerVersion: "etag-inst-2", + }; + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + // The occurrence was resolved off the series via fetchInstanceAt, by the + // master's provider id and the occurrence's original start. + expect(writer.fetchInstanceCalls).toHaveLength(1); + expect(writer.fetchInstanceCalls[0]).toMatchObject({ + calendarId: calendar.providerCalendarId, + seriesProviderEventId: "g-evt-1", + originalStartAt: SECOND_START_UTC, + scheduleKind: "timed", + }); + // The master itself was never fetched and never patched: the single + // patch targets the RESOLVED instance id, with no recurrence key. + expect(writer.fetchEventCalls).toHaveLength(0); + expect(writer.patchCalls).toHaveLength(1); + expect(writer.patchCalls[0]!.providerEventId).toBe("g-inst-1"); + expect(writer.patchCalls[0]!.recurrence).toEqual({ kind: "instance" }); + expect(writer.patchCalls[0]!.invitation).toBe("none"); + expect(writer.patchCalls[0]!.attendees).toEqual([ + attendee("organizer@example.com", "accepted"), + attendee(SELF, "declined"), + ]); + + // Locally: the master's stored guest list is untouched; the answer lives + // on the instance's exception record, carrying the instance's own + // provider identity. + const master = await events.findById(tenantId, principalId, event._id); + expect(master?.content.attendees).toEqual([ + attendee("organizer@example.com", "accepted"), + attendee(SELF, "accepted"), + ]); + expect(master?.providerVersion).toBe("etag-1" as ProviderEventVersion); + const exceptions = await events.findSeriesExceptions( + tenantId, + principalId, + event._id, + ); + expect(exceptions).toHaveLength(1); + expect(exceptions[0]?.providerEventId).toBe("g-inst-1" as ProviderEventId); + expect(exceptions[0]?.providerVersion).toBe( + "etag-inst-2" as ProviderEventVersion, + ); + expect(exceptions[0]?.content.attendees).toEqual([ + attendee("organizer@example.com", "accepted"), + attendee(SELF, "declined"), + ]); + + // Sibling occurrences are untouched: the master still projects 07-14 and + // 07-28, and the answered instant projects from the exception. + const masterRows = await mongo.db + .collection(SYNC_COLLECTIONS.eventOccurrences) + .find({ eventId: event._id }) + .toArray(); + expect( + masterRows.map((row) => (row["startAt"] as Date).toISOString()).sort(), + ).toEqual(["2026-07-14T15:00:00.000Z", "2026-07-28T15:00:00.000Z"]); + const exceptionRows = await mongo.db + .collection(SYNC_COLLECTIONS.eventOccurrences) + .find({ eventId: exceptions[0]?._id }) + .toArray(); + expect( + exceptionRows.map((row) => (row["startAt"] as Date).toISOString()), + ).toEqual([SECOND_START_UTC]); + }); + + it("patches the series master on a scope-all rsvp, never resolving an instance", async () => { + // The other half of the targeting proof: "all events" answers on the + // master itself. + const { tenantId, principalId, calendar, event } = await seedLinked({ + recurrence: { kind: "seriesMaster", rules: weekly3 }, + }); + const command = await rsvpCommand(event, { responseStatus: "declined" }); + const writer = new FakeProviderEventWriter(); + writer.fetchEventResult = { + ...providerSingle("etag-1", [ + attendee("organizer@example.com", "accepted"), + attendee(SELF, "accepted"), + ]), + recurrence: { kind: "seriesMaster", rules: weekly3 }, + }; + writer.patchResult = { + providerEventId: "g-evt-1" as ProviderEventId, + providerVersion: "etag-2", + }; + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.fetchInstanceCalls).toHaveLength(0); + expect(writer.fetchEventCalls).toHaveLength(1); + expect(writer.patchCalls).toHaveLength(1); + expect(writer.patchCalls[0]!.providerEventId).toBe("g-evt-1"); + // The master's own current rules are re-written unchanged + // (self-describing), mirroring how a "preserve" series edit writes. + expect(writer.patchCalls[0]!.recurrence).toEqual({ + kind: "series", + rules: weekly3, + }); + expect(writer.patchCalls[0]!.attendees).toEqual([ + attendee("organizer@example.com", "accepted"), + attendee(SELF, "declined"), + ]); + const master = await events.findById(tenantId, principalId, event._id); + expect(master?.content.attendees).toEqual([ + attendee("organizer@example.com", "accepted"), + attendee(SELF, "declined"), + ]); + }); + + it("keeps a scope-all rsvp from resurrecting a cancelled occurrence", async () => { + // The commit reprojects through reprojectMaster, so a previously deleted + // occurrence's instant stays excluded. + const { calendar, event } = await seedLinked({ + recurrence: { kind: "seriesMaster", rules: weekly3 }, + }); + await events.upsertException( + event, + SECOND_START_UTC as never, + { + content: event.content, + schedule, + cancelled: true, + providerIdentity: null, + }, + now(), + ); + const command = await rsvpCommand(event, { responseStatus: "declined" }); + const writer = new FakeProviderEventWriter(); + writer.fetchEventResult = { + ...providerSingle("etag-1", [attendee(SELF, "accepted")]), + recurrence: { kind: "seriesMaster", rules: weekly3 }, + }; + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + const masterRows = await mongo.db + .collection(SYNC_COLLECTIONS.eventOccurrences) + .find({ eventId: event._id }) + .toArray(); + expect( + masterRows.map((row) => (row["startAt"] as Date).toISOString()).sort(), + ).toEqual(["2026-07-14T15:00:00.000Z", "2026-07-28T15:00:00.000Z"]); + }); + + it("confirms a scope-this replay without a second write when the instance already holds the answer", async () => { + const { tenantId, principalId, calendar, event } = await seedLinked({ + recurrence: { kind: "seriesMaster", rules: weekly3 }, + }); + const command = await rsvpCommand(event, { + responseStatus: "declined", + scope: "this", + recurrenceId: SECOND_START_UTC, + }); + const writer = new FakeProviderEventWriter(); + writer.fetchInstanceResult = providerInstance([attendee(SELF, "declined")]); + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(0); + expect( + result.outcome.state === "confirmed" && result.outcome.providerVersion, + ).toBe("etag-inst-1" as ProviderEventVersion); + // The already-landed answer still converges locally onto the exception. + const exceptions = await events.findSeriesExceptions( + tenantId, + principalId, + event._id, + ); + expect(exceptions).toHaveLength(1); + expect(exceptions[0]?.content.attendees).toEqual([ + attendee(SELF, "declined"), + ]); + }); +}); diff --git a/packages/sync/src/domain/provider-command.rsvp.ts b/packages/sync/src/domain/provider-command.rsvp.ts new file mode 100644 index 0000000000..a1798c0049 --- /dev/null +++ b/packages/sync/src/domain/provider-command.rsvp.ts @@ -0,0 +1,317 @@ +import { type DateTime } from "@core/types/domain-primitives"; +import { type EventSchedule } from "@core/types/event.contracts"; +import { type Attendee } from "@core/types/event-attendance.contracts"; +import { + type ProviderEventVersion, + type SyncEventContent, +} from "@core/types/sync/event.contracts"; +import { type ProviderEventId } from "@core/types/sync/identity.contracts"; +import { type ProviderMutationDeps } from "@sync/domain/provider-command.deps"; +import { failCommand } from "@sync/domain/provider-command.internal"; +import { + resolveAccessToken, + runProviderWrite, +} from "@sync/domain/provider-write-ladder"; +import { reprojectOccurrences } from "@sync/domain/reproject"; +import { reprojectMaster } from "@sync/domain/series-exception"; +import { type ProviderEvent } from "@sync/providers/provider-event.port"; +import { type ProviderWriteRecurrence } from "@sync/providers/provider-event-writer.port"; +import { type CommandRecord } from "@sync/storage/contracts/command.contracts"; +import { type EventRecord } from "@sync/storage/contracts/event.contracts"; +import { type ProviderCalendarRecord } from "@sync/storage/contracts/provider-calendar.contracts"; + +function findSelfAttendee( + attendees: readonly Attendee[], + accountEmail: string, +): Attendee | undefined { + const email = accountEmail.toLowerCase(); + return attendees.find((attendee) => attendee.email.toLowerCase() === email); +} + +// Execute a Compass-initiated rsvp command against the owning provider. +// +// Targeting: scope "all" (and any single event) addresses the event itself — +// for a recurring series that is the SERIES MASTER, so the answer covers +// every occurrence. Scope "this" addresses ONE occurrence: the Google +// instance is resolved via the writer's fetchInstanceAt (the same +// occurrence-id decode update/delete use — an instance id is never +// hand-built here) and IT is patched, leaving the master and every sibling +// instance untouched. +// +// Replay safety: the freshly fetched self entry already holding the intended +// status means a prior attempt landed (or the user answered from another +// client) — confirm at the current provider version without a second write. +// The patch itself is UNCONDITIONAL (no If-Match): any other guest's +// concurrent RSVP bumps the provider version, and RSVP drift must never +// block an RSVP. The fetch→patch window this leaves open is the pack's +// named clobber-window wart, same as attendeesEdit "replace". +// +// Guards, all typed unsupportedCapability: an unverifiable connection +// (missing row / no account email) and a stored attendee list without the +// self entry both fail closed BEFORE any provider call; a fetched list +// without the self entry (removed provider-side since the last pull) fails +// after the fetch, without a write. There is deliberately NO organizer +// guard: the organizer RSVPing their own event is allowed — Google lists +// the organizer as an attendee of their own event. +export async function executeProviderRsvp( + deps: ProviderMutationDeps, + command: CommandRecord, + event: EventRecord, + calendar: ProviderCalendarRecord, + now: () => Date, +): Promise { + if (command.input.kind !== "rsvp") { + throw new Error("executeProviderRsvp requires an rsvp command"); + } + if (!event.connectionId || !event.providerEventId) { + throw new Error("executeProviderRsvp requires a linked event"); + } + const { input } = command; + const connectionId = event.connectionId; + const seriesProviderEventId = event.providerEventId; + + const connection = await deps.connections.findById( + command.tenantId, + command.principalId, + connectionId, + ); + const accountEmail = connection?.account.email ?? null; + if (accountEmail === null) { + return failCommand(deps, command, "unsupportedCapability", connectionId); + } + if (!findSelfAttendee(event.content.attendees, accountEmail)) { + return failCommand(deps, command, "unsupportedCapability", connectionId); + } + + const tokenResult = await resolveAccessToken(deps.custody, connectionId); + if (!tokenResult.ok) { + if (tokenResult.stop.kind === "pending") return command; + return failCommand(deps, command, tokenResult.stop.reason, connectionId); + } + const { accessToken } = tokenResult; + + const perOccurrence = + input.scope === "this" && + input.recurrenceId !== null && + event.recurrence.kind === "seriesMaster"; + + // Fetch the target's current provider state: the master (or single event) + // itself, or the ONE resolved instance for a per-occurrence answer. + const fetchResult = await runProviderWrite(() => + perOccurrence + ? deps.writer.fetchInstanceAt({ + accessToken, + calendarId: calendar.providerCalendarId, + seriesProviderEventId, + originalStartAt: input.recurrenceId as DateTime, + scheduleKind: event.schedule.kind, + }) + : deps.writer.fetchEvent({ + accessToken, + calendarId: calendar.providerCalendarId, + providerEventId: seriesProviderEventId, + }), + ); + if (!fetchResult.ok) { + if (fetchResult.stop.kind === "pending") return command; + return failCommand(deps, command, fetchResult.stop.reason, connectionId); + } + const current = + fetchResult.value?.kind === "event" ? fetchResult.value : null; + // Nothing live to answer: the event (or that one instance) no longer + // exists as a content event at the provider. + if (!current) { + return failCommand(deps, command, "permanentProviderError", connectionId); + } + + const selfIndex = current.content.attendees.findIndex( + (attendee) => attendee.email.toLowerCase() === accountEmail.toLowerCase(), + ); + // Removed from the guest list provider-side since the last pull: there is + // no self entry to rewrite (same typed refusal as the stored-list guard, + // just discovered one step later — after the fetch, before any write). + if (selfIndex === -1) { + return failCommand(deps, command, "unsupportedCapability", connectionId); + } + const currentSelf = current.content.attendees[selfIndex] as Attendee; + const alreadyAnswered = currentSelf.responseStatus === input.responseStatus; + + const intendedAttendees = alreadyAnswered + ? current.content.attendees + : current.content.attendees.map((attendee, index) => + index === selfIndex + ? { ...attendee, responseStatus: input.responseStatus } + : attendee, + ); + + let providerVersion = current.providerVersion; + if (!alreadyAnswered) { + const patchResult = await runProviderWrite(() => + deps.writer.patchEvent({ + accessToken, + calendarId: calendar.providerCalendarId, + providerEventId: current.providerEventId, + // Unconditional on purpose: another guest's concurrent RSVP bumps + // the provider version, and RSVP drift must never block an RSVP. + expectedVersion: null, + content: rsvpEchoContent(current.content), + schedule: current.schedule, + recurrence: rsvpWriteRecurrence(current.recurrence), + // Answering an invitation never emails anyone. + invitation: "none", + attendees: intendedAttendees, + }), + ); + if (!patchResult.ok) { + if (patchResult.stop.kind === "pending") return command; + return failCommand(deps, command, patchResult.stop.reason, connectionId); + } + providerVersion = patchResult.value.providerVersion; + } + + if (perOccurrence) { + return commitProviderOccurrenceRsvp( + deps, + command, + event, + input.recurrenceId as DateTime, + { ...current.content, attendees: intendedAttendees }, + current.schedule, + current.providerEventId, + providerVersion, + now, + ); + } + return commitProviderRsvp( + deps, + command, + event, + intendedAttendees, + providerVersion, + now, + ); +} + +// The non-attendee body an rsvp patch sends. The write port requires a full +// body, so the freshly fetched provider state is echoed back — re-writing +// the provider's own current values is self-describing (mirroring how a +// "preserve" series edit re-writes the current rules) and the only field +// that actually changes is the self entry riding the separate `attendees` +// input. color/colorHex are STRIPPED rather than echoed: colorHex is +// read-only, and a slot color in the body would trigger the writer's +// label-clearing pre-patch (an extra provider round-trip that could clear a +// label Compass never meant to touch) — an omitted color leaves Google's +// color state entirely alone under patch merge-by-key semantics. +function rsvpEchoContent(content: SyncEventContent): SyncEventContent { + const { color: _color, colorHex: _colorHex, ...rest } = content; + return rest; +} + +// The recurrence an rsvp patch re-writes, mapped from the fetched read: a +// series master re-writes its own current rules (harmless, self-describing); +// a resolved instance must OMIT the recurrence key entirely (Google rejects +// one on an instance — see ProviderWriteRecurrence); a single event stays +// single. +function rsvpWriteRecurrence( + current: ProviderEvent["recurrence"], +): ProviderWriteRecurrence { + if (current.kind === "seriesMaster") { + return { kind: "series", rules: current.rules }; + } + if (current.kind === "instance") return { kind: "instance" }; + return { kind: "single" }; +} + +// Commit a confirmed whole-event (or whole-series) rsvp locally: rewrite the +// stored record's attendee list — only that; pulls own every other field — +// bump the provider version, and reproject through reprojectMaster so a +// series' exception instants stay excluded (a cancelled occurrence must not +// be resurrected by an RSVP). A miss from replaceExisting means the event +// vanished mid-flight — leave the command pending rather than confirm +// against a gone event. +async function commitProviderRsvp( + deps: ProviderMutationDeps, + command: CommandRecord, + event: EventRecord, + attendees: readonly Attendee[], + providerVersion: string, + now: () => Date, +): Promise { + const updated: EventRecord = { + ...event, + content: { ...event.content, attendees }, + providerVersion: providerVersion as ProviderEventVersion, + providerUpdatedAt: null, + deliveryState: "confirmed", + updatedAt: now(), + }; + const applied = await deps.events.replaceExisting(updated); + if (!applied) return command; + await reprojectMaster(deps, command, updated, now); + + const confirmed = await deps.commands.updateOutcome( + command.tenantId, + command.principalId, + command._id, + { + state: "confirmed", + providerEventId: event.providerEventId as ProviderEventId, + providerVersion: providerVersion as ProviderEventVersion, + }, + command.attemptCount, + ); + return confirmed ?? command; +} + +// Commit a confirmed per-occurrence rsvp locally: upsert the exception +// carrying the INSTANCE's own provider identity and its fetched content with +// the rewritten self entry (what a pull of that instance would store), +// reproject the master to exclude that instant, then project the exception's +// own occurrence — the same local-commit shape a scope-"this" edit uses, so +// the next backend read reflects the answer before Google round-trips. +async function commitProviderOccurrenceRsvp( + deps: ProviderMutationDeps, + command: CommandRecord, + master: EventRecord, + recurrenceId: DateTime, + content: SyncEventContent, + schedule: EventSchedule, + providerEventId: string, + providerVersion: string, + now: () => Date, +): Promise { + const exception = await deps.events.upsertException( + master, + recurrenceId, + { + content, + schedule, + cancelled: false, + providerIdentity: { + providerEventId: providerEventId as ProviderEventId, + providerVersion: providerVersion as ProviderEventVersion, + }, + }, + now(), + ); + await reprojectMaster(deps, command, master, now); + await reprojectOccurrences(deps.occurrences, exception, now); + + const confirmed = await deps.commands.updateOutcome( + command.tenantId, + command.principalId, + command._id, + { + state: "confirmed", + providerEventId: providerEventId as ProviderEventId, + providerVersion: providerVersion as ProviderEventVersion, + }, + command.attemptCount, + ); + return confirmed ?? command; +} + +// The provider recurrence a series edit-all writes. "series" sets new rules; +// "single" removes recurrence (converting the series to one event); "preserve" +// re-writes the master's current rules unchanged (harmless, keeps the write +// self-describing). diff --git a/packages/sync/src/domain/provider-command.series-following.ts b/packages/sync/src/domain/provider-command.series-following.ts new file mode 100644 index 0000000000..881d0f7ed5 --- /dev/null +++ b/packages/sync/src/domain/provider-command.series-following.ts @@ -0,0 +1,365 @@ +import { type DateTime } from "@core/types/domain-primitives"; +import { type ProviderEventVersion } from "@core/types/sync/event.contracts"; +import { type ProviderEventId } from "@core/types/sync/identity.contracts"; +import { + scheduleStartAt, + truncateRulesBefore, +} from "@sync/domain/occurrence-projection"; +import { executeProviderDelete } from "@sync/domain/provider-command.delete"; +import { + type ProviderDeleteDeps, + type ProviderMutationDeps, +} from "@sync/domain/provider-command.deps"; +import { + matchesIntendedEdit, + patchExpectedVersion, +} from "@sync/domain/provider-command.intent-match"; +import { failCommand } from "@sync/domain/provider-command.internal"; +import { executeProviderSeriesUpdate } from "@sync/domain/provider-command.series-update"; +import { + resolveAccessToken, + runProviderWrite, +} from "@sync/domain/provider-write-ladder"; +import { reprojectOccurrences } from "@sync/domain/reproject"; +import { + buildRemainderMaster, + deleteFollowingExceptions, + reprojectMaster, + truncatedSeriesMaster, +} from "@sync/domain/series-exception"; +import { type ProviderWriteRecurrence } from "@sync/providers/provider-event-writer.port"; +import { type CommandRecord } from "@sync/storage/contracts/command.contracts"; +import { type EventRecord } from "@sync/storage/contracts/event.contracts"; +import { type ProviderCalendarRecord } from "@sync/storage/contracts/provider-calendar.contracts"; + +export async function executeProviderSeriesFollowingDelete( + deps: ProviderDeleteDeps, + command: CommandRecord, + master: EventRecord, + calendar: ProviderCalendarRecord, + now: () => Date, +): Promise { + if (command.input.kind !== "delete" || command.input.recurrenceId === null) { + throw new Error( + "executeProviderSeriesFollowingDelete requires a thisAndFollowing-scope delete command", + ); + } + if (!master.connectionId || !master.providerEventId) { + throw new Error( + "executeProviderSeriesFollowingDelete requires a linked series master", + ); + } + if (master.recurrence.kind !== "seriesMaster") { + throw new Error( + "executeProviderSeriesFollowingDelete requires a series master", + ); + } + const { input } = command; + const connectionId = master.connectionId; + const providerEventId = master.providerEventId; + const splitAt = new Date(input.recurrenceId as DateTime); + + if (splitAt.getTime() <= scheduleStartAt(master.schedule).getTime()) { + return executeProviderDelete(deps, command, master, calendar, now); + } + + const tokenResult = await resolveAccessToken(deps.custody, connectionId); + if (!tokenResult.ok) { + if (tokenResult.stop.kind === "pending") return command; + return failCommand(deps, command, tokenResult.stop.reason, connectionId); + } + const { accessToken } = tokenResult; + + await deleteFollowingExceptions(deps, command, master._id, splitAt); + const truncatedRules = truncateRulesBefore(master.recurrence.rules, splitAt); + const location = { + accessToken, + calendarId: calendar.providerCalendarId, + providerEventId, + }; + + const fetchResult = await runProviderWrite(() => + deps.writer.fetchEvent(location), + ); + if (!fetchResult.ok) { + if (fetchResult.stop.kind === "pending") return command; + return failCommand(deps, command, fetchResult.stop.reason, connectionId); + } + const current = + fetchResult.value?.kind === "event" ? fetchResult.value : null; + if (!current) { + return failCommand(deps, command, "permanentProviderError", connectionId); + } + + const truncateRecurrence: ProviderWriteRecurrence = { + kind: "series", + rules: truncatedRules, + }; + if ( + matchesIntendedEdit( + current, + master.content, + master.schedule, + truncateRecurrence, + ) + ) { + return commitProviderSeriesFollowingDelete( + deps, + command, + master, + splitAt, + current.providerVersion, + now, + ); + } + + const patchResult = await runProviderWrite(() => + deps.writer.patchEvent({ + ...location, + expectedVersion: patchExpectedVersion(command, current, master), + content: master.content, + schedule: master.schedule, + recurrence: truncateRecurrence, + invitation: input.invitation, + }), + ); + if (!patchResult.ok) { + if (patchResult.stop.kind === "pending") return command; + return failCommand(deps, command, patchResult.stop.reason, connectionId); + } + const result = patchResult.value; + + return commitProviderSeriesFollowingDelete( + deps, + command, + master, + splitAt, + result.providerVersion, + now, + ); +} + +async function commitProviderSeriesFollowingDelete( + deps: ProviderMutationDeps, + command: CommandRecord, + master: EventRecord, + splitAt: Date, + providerVersion: string, + now: () => Date, +): Promise { + const truncated: EventRecord = { + ...truncatedSeriesMaster(master, splitAt, now()), + providerVersion: providerVersion as ProviderEventVersion, + providerUpdatedAt: null, + deliveryState: "confirmed", + }; + const applied = await deps.events.replaceExisting(truncated); + if (!applied) return command; + await reprojectMaster(deps, command, truncated, now); + + const confirmed = await deps.commands.updateOutcome( + command.tenantId, + command.principalId, + command._id, + { + state: "confirmed", + providerEventId: master.providerEventId as ProviderEventId, + providerVersion: providerVersion as ProviderEventVersion, + }, + command.attemptCount, + ); + return confirmed ?? command; +} + +// Apply a Compass-initiated scope-"thisAndFollowing" EDIT to a provider-linked +// series by SPLITTING it, mirroring updateCloudSeriesFollowing: truncate the +// original master's rules at the provider (same as the delete twin above), +// then CREATE a new remainder series at the provider carrying the edit, at a +// deterministic provider event id so a retry converges on one remainder +// instead of duplicating it. A split at the series' own first occurrence +// edits the whole series, so it collapses to the existing provider edit-all. +export async function executeProviderSeriesFollowingUpdate( + deps: ProviderMutationDeps, + command: CommandRecord, + master: EventRecord, + calendar: ProviderCalendarRecord, + now: () => Date, +): Promise { + if (command.input.kind !== "update" || command.input.recurrenceId === null) { + throw new Error( + "executeProviderSeriesFollowingUpdate requires a thisAndFollowing-scope update command", + ); + } + if (!master.connectionId || !master.providerEventId) { + throw new Error( + "executeProviderSeriesFollowingUpdate requires a linked series master", + ); + } + if (master.recurrence.kind !== "seriesMaster") { + throw new Error( + "executeProviderSeriesFollowingUpdate requires a series master", + ); + } + const { input } = command; + const connectionId = master.connectionId; + const providerEventId = master.providerEventId; + const splitAt = new Date(input.recurrenceId as DateTime); + + if (splitAt.getTime() <= scheduleStartAt(master.schedule).getTime()) { + return executeProviderSeriesUpdate(deps, command, master, calendar, now); + } + + // Same v1 rule as the occurrence path: a guest-list replace has no defined + // semantics on a thisAndFollowing split (which guest list would the + // truncated original keep?), so refuse typed rather than silently + // preserving. + if (input.attendeesEdit === "replace") { + return failCommand(deps, command, "unsupportedCapability", connectionId); + } + + const tokenResult = await resolveAccessToken(deps.custody, connectionId); + if (!tokenResult.ok) { + if (tokenResult.stop.kind === "pending") return command; + return failCommand(deps, command, tokenResult.stop.reason, connectionId); + } + const { accessToken } = tokenResult; + + // Truncate the original master first — same ordering as the cloud path: + // the worst transient state between this step and the remainder create + // below is a momentary gap at the split, never a duplicate series. + await deleteFollowingExceptions(deps, command, master._id, splitAt); + const truncatedRules = truncateRulesBefore(master.recurrence.rules, splitAt); + const originalLocation = { + accessToken, + calendarId: calendar.providerCalendarId, + providerEventId, + }; + + const fetchResult = await runProviderWrite(() => + deps.writer.fetchEvent(originalLocation), + ); + if (!fetchResult.ok) { + if (fetchResult.stop.kind === "pending") return command; + return failCommand(deps, command, fetchResult.stop.reason, connectionId); + } + const current = + fetchResult.value?.kind === "event" ? fetchResult.value : null; + if (!current) { + return failCommand(deps, command, "permanentProviderError", connectionId); + } + + const truncateRecurrence: ProviderWriteRecurrence = { + kind: "series", + rules: truncatedRules, + }; + let originalVersion: string; + if ( + matchesIntendedEdit( + current, + master.content, + master.schedule, + truncateRecurrence, + ) + ) { + originalVersion = current.providerVersion; + } else { + const truncateResult = await runProviderWrite(() => + deps.writer.patchEvent({ + ...originalLocation, + expectedVersion: patchExpectedVersion(command, current, master), + content: master.content, + schedule: master.schedule, + recurrence: truncateRecurrence, + invitation: input.invitation, + }), + ); + if (!truncateResult.ok) { + if (truncateResult.stop.kind === "pending") return command; + return failCommand( + deps, + command, + truncateResult.stop.reason, + connectionId, + ); + } + originalVersion = truncateResult.value.providerVersion; + } + + const truncated: EventRecord = { + ...truncatedSeriesMaster(master, splitAt, now()), + providerVersion: originalVersion as ProviderEventVersion, + providerUpdatedAt: null, + deliveryState: "confirmed", + }; + const appliedTruncate = await deps.events.replaceExisting(truncated); + if (!appliedTruncate) return command; + await reprojectMaster(deps, command, truncated, now); + + // Remainder comes from the original (pre-truncation) master. "preserve" + // must not use intendedSeriesRecurrence, which would re-write the already + // bounded rules the original just got truncated to. + const remainderDraft = buildRemainderMaster(master, command, now()); + const remainderRecurrence: ProviderWriteRecurrence = + remainderDraft.recurrence.kind === "seriesMaster" + ? { kind: "series", rules: remainderDraft.recurrence.rules } + : { kind: "single" }; + + const createResultAttempt = await runProviderWrite(() => + deps.writer.createEvent({ + accessToken, + calendarId: calendar.providerCalendarId, + providerEventId: remainderDraft._id, + content: remainderDraft.content, + schedule: remainderDraft.schedule, + recurrence: remainderRecurrence, + invitation: input.invitation, + }), + ); + if (!createResultAttempt.ok) { + if (createResultAttempt.stop.kind === "pending") return command; + return failCommand( + deps, + command, + createResultAttempt.stop.reason, + connectionId, + ); + } + const createResult = createResultAttempt.value; + + const remainder: EventRecord = { + ...remainderDraft, + providerEventId: createResult.providerEventId as ProviderEventId, + providerVersion: createResult.providerVersion as ProviderEventVersion, + providerUpdatedAt: null, + deliveryState: "confirmed", + }; + await deps.events.put(remainder); + await reprojectOccurrences(deps.occurrences, remainder, now); + + const confirmed = await deps.commands.updateOutcome( + command.tenantId, + command.principalId, + command._id, + { + state: "confirmed", + providerEventId: createResult.providerEventId as ProviderEventId, + providerVersion: createResult.providerVersion as ProviderEventVersion, + }, + command.attemptCount, + ); + return confirmed ?? command; +} + +// --------------------------------------------------------------------------- +// RSVP. +// +// An RSVP is not a content edit: it rewrites exactly ONE attendee entry — the +// connection account's own, matched case-insensitively by email — and leaves +// every other entry byte-identical to the freshly fetched provider state. +// Because a Google patch replaces the WHOLE attendees array, the write sends +// the full merged list, and sendUpdates is always "none": answering an +// invitation must never email the guest list. +// --------------------------------------------------------------------------- + +// The caller's own attendee entry, matched case-insensitively by the +// connection's account email. Alias emails not matching is a named wart. diff --git a/packages/sync/src/domain/provider-command.series-update.db.test.ts b/packages/sync/src/domain/provider-command.series-update.db.test.ts new file mode 100644 index 0000000000..fd962219dc --- /dev/null +++ b/packages/sync/src/domain/provider-command.series-update.db.test.ts @@ -0,0 +1,342 @@ +import { faker } from "@faker-js/faker"; +import { + type DateTime, + type EventId, + type TimeZone, +} from "@core/types/domain-primitives"; +import { type RecurrenceEdit } from "@core/types/event-command.contracts"; +import { type SyncCommandInput } from "@core/types/sync/command.contracts"; +import { type ProviderEventVersion } from "@core/types/sync/event.contracts"; +import { + type IdempotencyKey, + type ProviderEventId, +} from "@core/types/sync/identity.contracts"; +import { + bindCommandRepos, + COMMAND_NOW, + FakeProviderEventWriter, + newCommandIds, + providerMutationDeps, + seedCommandCalendar, + seedLinkedEvent, +} from "@sync/__tests__/helpers/command-scenario"; +import { setupSyncStorage } from "@sync/__tests__/helpers/storage"; +import { executeProviderSeriesUpdate } from "@sync/domain/provider-command.series-update"; +import { reprojectOccurrences } from "@sync/domain/reproject"; +import { type ProviderEvent } from "@sync/providers/provider-event.port"; +import { + type ProviderEventWriter, + ProviderWriteError, +} from "@sync/providers/provider-event-writer.port"; +import { SYNC_COLLECTIONS } from "@sync/storage/collections"; +import { type EventRecord } from "@sync/storage/contracts/event.contracts"; +import { type CommandRepository } from "@sync/storage/repositories/command.repository"; +import { type CredentialRepository } from "@sync/storage/repositories/credential.repository"; +import { type DeletionMarkerRepository } from "@sync/storage/repositories/deletion-marker.repository"; +import { type EventRepository } from "@sync/storage/repositories/event.repository"; +import { type EventOccurrenceRepository } from "@sync/storage/repositories/event-occurrence.repository"; +import { type ProviderCalendarRepository } from "@sync/storage/repositories/provider-calendar.repository"; +import { type SyncResourceRepository } from "@sync/storage/repositories/sync-resource.repository"; +import { type SyncMongoService } from "@sync/storage/sync-mongo.service"; +import { beforeEach, describe, expect, it } from "bun:test"; + +const storage = setupSyncStorage(import.meta.url); +const repos = bindCommandRepos(storage); +const objectId = () => faker.database.mongodbObjectId(); +const now = COMMAND_NOW; + +let mongo: SyncMongoService; +let commands: CommandRepository; +let events: EventRepository; +let occurrences: EventOccurrenceRepository; +let resources: SyncResourceRepository; +let calendars: ProviderCalendarRepository; +let _markers: DeletionMarkerRepository; +let _credentials: CredentialRepository; + +beforeEach(() => { + mongo = repos.mongo; + commands = repos.commands; + events = repos.events; + occurrences = repos.occurrences; + resources = repos.resources; + calendars = repos.calendars; + _markers = repos.markers; + _credentials = repos.credentials; +}); + +describe("executeProviderSeriesUpdate", () => { + // A weekly series of four occurrences starting 2026-07-14 09:00 Denver. + const schedule = { + kind: "timed" as const, + start: "2026-07-14T09:00:00-06:00" as DateTime, + end: "2026-07-14T10:00:00-06:00" as DateTime, + timeZone: "America/Denver" as TimeZone, + }; + const weekly4 = ["RRULE:FREQ=WEEKLY;COUNT=4"]; + const weekly2 = ["RRULE:FREQ=WEEKLY;COUNT=2"]; + const content = (title: string) => ({ + title, + description: "", + location: null, + organizer: null, + attendees: [], + conference: null, + }); + // The provider's view of the series master, with its current rules. + const providerSeries = ( + title: string, + version: string, + rules: readonly string[], + ): ProviderEvent => ({ + kind: "event", + providerEventId: "g-evt-1" as ProviderEventId, + providerVersion: version, + providerUpdatedAt: null, + content: content(title), + schedule, + busy: true, + recurrence: { kind: "seriesMaster", rules: [...rules] }, + }); + + // Seed a provider-linked series master ("Old", weekly x4 at etag-1). + const seedMaster = async () => { + const ids = newCommandIds(); + const calendar = await seedCommandCalendar(calendars, ids); + const master = await seedLinkedEvent(events, { + ids, + calendarId: calendar._id, + content: content("Old"), + schedule, + recurrence: { kind: "seriesMaster", rules: [...weekly4] }, + now: now(), + }); + await reprojectOccurrences(occurrences, master, now); + return { + tenantId: ids.tenantId, + principalId: ids.principalId, + calendar, + master, + }; + }; + + // An edit-all update command for the seeded master. + const editAllCommand = async ( + master: EventRecord, + edit: { + title: string; + recurrence: RecurrenceEdit; + schedule?: typeof schedule; + }, + ) => + ( + await commands.submit({ + tenantId: master.tenantId, + principalId: master.principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId: master._id, + input: { + kind: "update", + invitation: "all", + content: content(edit.title), + schedule: edit.schedule ?? schedule, + recurrence: edit.recurrence, + scope: "all", + recurrenceId: null, + } as unknown as SyncCommandInput, + expectedVersion: "etag-1" as never, + }) + ).record; + + const masterOccurrences = (eventId: EventId) => + mongo.db + .collection(SYNC_COLLECTIONS.eventOccurrences) + .find({ eventId }) + .sort({ startAt: 1 }) + .toArray(); + + const deps = (writer: ProviderEventWriter) => + providerMutationDeps({ commands, events, occurrences, resources }, writer); + + it("patches the whole series and reprojects with the edited content", async () => { + const { tenantId, principalId, calendar, master } = await seedMaster(); + const command = await editAllCommand(master, { + title: "New", + recurrence: { kind: "preserve" }, + }); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerSeries("Old", "etag-1", weekly4); + writer.patchResult = { + providerEventId: "g-evt-1" as ProviderEventId, + providerVersion: "etag-2", + }; + + const result = await executeProviderSeriesUpdate( + deps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + // Preserve re-writes the master's own rules; the whole series is patched. + expect(writer.patchCalls).toHaveLength(1); + expect(writer.patchCalls[0]!.recurrence).toEqual({ + kind: "series", + rules: [...weekly4], + }); + const stored = await events.findById(tenantId, principalId, master._id); + expect(stored?.content.title).toBe("New"); + expect(stored?.providerVersion).toBe("etag-2" as ProviderEventVersion); + expect(stored?.recurrence).toEqual({ + kind: "seriesMaster", + rules: [...weekly4], + }); + // All four occurrences carry the edited title. + const occ = await masterOccurrences(master._id); + expect(occ).toHaveLength(4); + expect(occ.every((o) => o["title"] === "New")).toBe(true); + }); + + it("patches on a rules-only edit instead of treating it as a replay", async () => { + const { tenantId, principalId, calendar, master } = await seedMaster(); + // Same title and schedule, only the recurrence rule shrinks 4 -> 2. Without + // comparing recurrence this would look identical to the provider's current + // state and be confirmed WITHOUT ever writing the new rule. + const command = await editAllCommand(master, { + title: "Old", + recurrence: { kind: "series", rules: weekly2 }, + }); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerSeries("Old", "etag-1", weekly4); + writer.patchResult = { + providerEventId: "g-evt-1" as ProviderEventId, + providerVersion: "etag-2", + }; + + const result = await executeProviderSeriesUpdate( + deps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(1); + expect(writer.patchCalls[0]!.recurrence).toEqual({ + kind: "series", + rules: [...weekly2], + }); + const stored = await events.findById(tenantId, principalId, master._id); + expect(stored?.recurrence).toEqual({ + kind: "seriesMaster", + rules: [...weekly2], + }); + // The horizon now holds only the two remaining occurrences. + expect(await masterOccurrences(master._id)).toHaveLength(2); + }); + + it("confirms without re-patching when the whole edit already landed", async () => { + const { calendar, master } = await seedMaster(); + const command = await editAllCommand(master, { + title: "New", + recurrence: { kind: "series", rules: weekly2 }, + }); + const writer = new FakeProviderEventWriter(); + // Provider already holds the edited content AND the new rules at a fresh + // version — a prior attempt landed before the crash. + writer.fetched = providerSeries("New", "etag-2", weekly2); + + const result = await executeProviderSeriesUpdate( + deps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(0); + }); + + it("treats a reformatted-but-equivalent rule echo as a replay", async () => { + const { calendar, master } = await seedMaster(); + const command = await editAllCommand(master, { + title: "New", + recurrence: { + kind: "series", + rules: ["RRULE:FREQ=WEEKLY;COUNT=4;INTERVAL=1"], + }, + }); + const writer = new FakeProviderEventWriter(); + // The provider echoes the same rule reordered and lowercased at a new + // version — our edit landed on a prior attempt. A byte-for-byte compare + // would miss it and re-patch with a now-stale version, failing a write that + // already succeeded. + writer.fetched = providerSeries("New", "etag-2", [ + "rrule:interval=1;count=4;freq=weekly", + ]); + + const result = await executeProviderSeriesUpdate( + deps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(0); + }); + + it("patches against the fresh version when only the master's version key drifted", async () => { + const { calendar, master } = await seedMaster(); + const command = await editAllCommand(master, { + title: "New", + recurrence: { kind: "preserve" }, + }); + const writer = new FakeProviderEventWriter(); + // Same content, schedule, and rules as stored; only the version rotated. + writer.fetched = providerSeries("Old", "etag-1-rotated", weekly4); + + const result = await executeProviderSeriesUpdate( + deps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(1); + expect(writer.patchCalls[0]!.expectedVersion).toBe("etag-1-rotated"); + }); + + it("fails with a conflict on a genuine concurrent external edit", async () => { + const { calendar, master } = await seedMaster(); + const command = await editAllCommand(master, { + title: "New", + recurrence: { kind: "preserve" }, + }); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerSeries("Someone else", "etag-9", weekly4); + writer.patchError = new ProviderWriteError("versionConflict", "stale"); + + const result = await executeProviderSeriesUpdate( + deps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("failed"); + expect( + result.outcome.state === "failed" && result.outcome.failureReason, + ).toBe("versionConflict"); + // Content changed too, so the stale version stays on the patch. + expect(writer.patchCalls[0]!.expectedVersion).toBe("etag-1"); + }); +}); diff --git a/packages/sync/src/domain/provider-command.series-update.exceptions.db.test.ts b/packages/sync/src/domain/provider-command.series-update.exceptions.db.test.ts new file mode 100644 index 0000000000..4c441148b1 --- /dev/null +++ b/packages/sync/src/domain/provider-command.series-update.exceptions.db.test.ts @@ -0,0 +1,513 @@ +import { faker } from "@faker-js/faker"; +import { + type DateTime, + type EventId, + type TimeZone, +} from "@core/types/domain-primitives"; +import { type RecurrenceEdit } from "@core/types/event-command.contracts"; +import { type SyncCommandInput } from "@core/types/sync/command.contracts"; +import { + type IdempotencyKey, + type ProviderEventId, +} from "@core/types/sync/identity.contracts"; +import { + bindCommandRepos, + COMMAND_NOW, + FakeProviderEventWriter, + newCommandIds, + providerMutationDeps, + seedCommandCalendar, + seedLinkedEvent, +} from "@sync/__tests__/helpers/command-scenario"; +import { setupSyncStorage } from "@sync/__tests__/helpers/storage"; +import { executeProviderSeriesUpdate } from "@sync/domain/provider-command.series-update"; +import { reprojectOccurrences } from "@sync/domain/reproject"; +import { type ProviderEvent } from "@sync/providers/provider-event.port"; +import { + type ProviderEventWriter, + ProviderWriteError, +} from "@sync/providers/provider-event-writer.port"; +import { SYNC_COLLECTIONS } from "@sync/storage/collections"; +import { type EventRecord } from "@sync/storage/contracts/event.contracts"; +import { type CommandRepository } from "@sync/storage/repositories/command.repository"; +import { type CredentialRepository } from "@sync/storage/repositories/credential.repository"; +import { type DeletionMarkerRepository } from "@sync/storage/repositories/deletion-marker.repository"; +import { type EventRepository } from "@sync/storage/repositories/event.repository"; +import { type EventOccurrenceRepository } from "@sync/storage/repositories/event-occurrence.repository"; +import { type ProviderCalendarRepository } from "@sync/storage/repositories/provider-calendar.repository"; +import { type SyncResourceRepository } from "@sync/storage/repositories/sync-resource.repository"; +import { type SyncMongoService } from "@sync/storage/sync-mongo.service"; +import { beforeEach, describe, expect, it } from "bun:test"; + +const storage = setupSyncStorage(import.meta.url); +const repos = bindCommandRepos(storage); +const objectId = () => faker.database.mongodbObjectId(); +const now = COMMAND_NOW; + +let mongo: SyncMongoService; +let commands: CommandRepository; +let events: EventRepository; +let occurrences: EventOccurrenceRepository; +let resources: SyncResourceRepository; +let calendars: ProviderCalendarRepository; +let _markers: DeletionMarkerRepository; +let _credentials: CredentialRepository; + +beforeEach(() => { + mongo = repos.mongo; + commands = repos.commands; + events = repos.events; + occurrences = repos.occurrences; + resources = repos.resources; + calendars = repos.calendars; + _markers = repos.markers; + _credentials = repos.credentials; +}); + +describe("executeProviderSeriesUpdate", () => { + // A weekly series of four occurrences starting 2026-07-14 09:00 Denver. + const schedule = { + kind: "timed" as const, + start: "2026-07-14T09:00:00-06:00" as DateTime, + end: "2026-07-14T10:00:00-06:00" as DateTime, + timeZone: "America/Denver" as TimeZone, + }; + const weekly4 = ["RRULE:FREQ=WEEKLY;COUNT=4"]; + const weekly2 = ["RRULE:FREQ=WEEKLY;COUNT=2"]; + const content = (title: string) => ({ + title, + description: "", + location: null, + organizer: null, + attendees: [], + conference: null, + }); + // The provider's view of the series master, with its current rules. + const providerSeries = ( + title: string, + version: string, + rules: readonly string[], + ): ProviderEvent => ({ + kind: "event", + providerEventId: "g-evt-1" as ProviderEventId, + providerVersion: version, + providerUpdatedAt: null, + content: content(title), + schedule, + busy: true, + recurrence: { kind: "seriesMaster", rules: [...rules] }, + }); + + // Seed a provider-linked series master ("Old", weekly x4 at etag-1). + const seedMaster = async () => { + const ids = newCommandIds(); + const calendar = await seedCommandCalendar(calendars, ids); + const master = await seedLinkedEvent(events, { + ids, + calendarId: calendar._id, + content: content("Old"), + schedule, + recurrence: { kind: "seriesMaster", rules: [...weekly4] }, + now: now(), + }); + await reprojectOccurrences(occurrences, master, now); + return { + tenantId: ids.tenantId, + principalId: ids.principalId, + calendar, + master, + }; + }; + + // An edit-all update command for the seeded master. + const editAllCommand = async ( + master: EventRecord, + edit: { + title: string; + recurrence: RecurrenceEdit; + schedule?: typeof schedule; + }, + ) => + ( + await commands.submit({ + tenantId: master.tenantId, + principalId: master.principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId: master._id, + input: { + kind: "update", + invitation: "all", + content: content(edit.title), + schedule: edit.schedule ?? schedule, + recurrence: edit.recurrence, + scope: "all", + recurrenceId: null, + } as unknown as SyncCommandInput, + expectedVersion: "etag-1" as never, + }) + ).record; + + const masterOccurrences = (eventId: EventId) => + mongo.db + .collection(SYNC_COLLECTIONS.eventOccurrences) + .find({ eventId }) + .sort({ startAt: 1 }) + .toArray(); + + // Seed a provider-linked series exception. Real provider exceptions come from + // import (a later slice) and each carries its OWN provider event id, so this + // seeds a distinct providerEventId rather than reusing the master's — the + // provider-identity unique index forbids sharing it. + const putException = async ( + master: EventRecord, + opts: { + providerEventId: string; + recurrenceId: string; + cancelled: boolean; + title: string; + }, + ): Promise => { + const id = objectId() as EventId; + await events.put({ + _id: id, + tenantId: master.tenantId, + principalId: master.principalId, + origin: "compass", + calendarId: master.calendarId, + clientEventId: null, + connectionId: master.connectionId, + providerEventId: opts.providerEventId as never, + providerVersion: "etag-1" as never, + providerUpdatedAt: null, + deliveryState: "confirmed", + providerMetadata: null, + content: content(opts.title), + schedule, + recurrence: { + kind: "exception", + seriesId: master._id, + recurrenceId: opts.recurrenceId as never, + cancelled: opts.cancelled, + }, + lifecycleState: "active", + generation: 0, + createdAt: now(), + updatedAt: now(), + confirmedAt: now(), + } as never); + const stored = await events.findById( + master.tenantId, + master.principalId, + id, + ); + if (!stored) throw new Error("seed failed to read back the exception"); + return stored; + }; + + const deps = (writer: ProviderEventWriter) => + providerMutationDeps({ commands, events, occurrences, resources }, writer); + + it("discards override exceptions but keeps cancelled tombstones", async () => { + const { tenantId, principalId, calendar, master } = await seedMaster(); + // An override on the 2nd instant and a cancellation on the 3rd. + const override = await putException(master, { + providerEventId: "g-inst-override" as ProviderEventId, + recurrenceId: "2026-07-21T09:00:00-06:00", + cancelled: false, + title: "Moved", + }); + await putException(master, { + providerEventId: "g-inst-cancelled" as ProviderEventId, + recurrenceId: "2026-07-28T09:00:00-06:00", + cancelled: true, + title: "Old", + }); + const command = await editAllCommand(master, { + title: "New", + recurrence: { kind: "preserve" }, + }); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerSeries("Old", "etag-1", weekly4); + + const result = await executeProviderSeriesUpdate( + deps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + // Align the content override at the provider (do not cancel it); leave the + // kept tombstone untouched. + expect(writer.deleteCalls).toHaveLength(0); + const overridePatch = writer.patchCalls.find( + (call) => call.providerEventId === "g-inst-override", + ); + expect(overridePatch).toMatchObject({ + providerEventId: "g-inst-override" as ProviderEventId, + expectedVersion: null, + invitation: "all", + calendarId: calendar.providerCalendarId, + recurrence: { kind: "instance" }, + content: expect.objectContaining({ title: "New" }), + schedule: { + kind: "timed", + start: "2026-07-21T09:00:00-06:00" as DateTime, + end: "2026-07-21T10:00:00-06:00" as DateTime, + timeZone: "America/Denver" as TimeZone, + }, + }); + // The override is gone; the cancelled tombstone survives the edit. + expect( + await events.findById(tenantId, principalId, override._id), + ).toBeNull(); + const remaining = await events.findSeriesExceptions( + tenantId, + principalId, + master._id, + ); + expect(remaining).toHaveLength(1); + expect( + remaining[0]?.recurrence.kind === "exception" && + remaining[0]?.recurrence.cancelled, + ).toBe(true); + // The reprojected master excludes the cancelled instant (2026-07-28 15:00Z) + // but re-covers the formerly-overridden instant, so three master rows remain. + const occ = await masterOccurrences(master._id); + const starts = occ.map((o) => (o["startAt"] as Date).toISOString()); + expect(starts).toEqual([ + "2026-07-14T15:00:00.000Z", + "2026-07-21T15:00:00.000Z", + "2026-08-04T15:00:00.000Z", + ]); + }); + + it("leaves the override local when provider override align is transient", async () => { + const { tenantId, principalId, calendar, master } = await seedMaster(); + const override = await putException(master, { + providerEventId: "g-inst-override" as ProviderEventId, + recurrenceId: "2026-07-21T09:00:00-06:00", + cancelled: false, + title: "Moved", + }); + const command = await editAllCommand(master, { + title: "New", + recurrence: { kind: "preserve" }, + }); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerSeries("Old", "etag-1", weekly4); + writer.instancePatchError = new ProviderWriteError( + "transient", + "rate limited", + ); + + const result = await executeProviderSeriesUpdate( + deps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("pending"); + expect(writer.deleteCalls).toHaveLength(0); + expect( + writer.patchCalls.some( + (call) => call.providerEventId === "g-inst-override", + ), + ).toBe(true); + expect( + await events.findById(tenantId, principalId, override._id), + ).not.toBeNull(); + }); + + it("aligns discarded overrides to a series time change", async () => { + const { calendar, master } = await seedMaster(); + await putException(master, { + providerEventId: "g-inst-override" as ProviderEventId, + recurrenceId: "2026-07-21T09:00:00-06:00", + cancelled: false, + title: "Moved", + }); + const movedSchedule = { + kind: "timed" as const, + start: "2026-07-14T10:00:00-06:00" as DateTime, + end: "2026-07-14T11:00:00-06:00" as DateTime, + timeZone: "America/Denver" as TimeZone, + }; + const command = await editAllCommand(master, { + title: "New", + recurrence: { kind: "preserve" }, + schedule: movedSchedule, + }); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerSeries("Old", "etag-1", weekly4); + + const result = await executeProviderSeriesUpdate( + deps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + const overridePatch = writer.patchCalls.find( + (call) => call.providerEventId === "g-inst-override", + ); + expect(overridePatch?.schedule).toEqual({ + kind: "timed", + start: "2026-07-21T10:00:00-06:00" as DateTime, + end: "2026-07-21T11:00:00-06:00" as DateTime, + timeZone: "America/Denver" as TimeZone, + }); + }); + + it("continues edit-all when the override is already gone at the provider", async () => { + const { tenantId, principalId, calendar, master } = await seedMaster(); + const override = await putException(master, { + providerEventId: "g-inst-override" as ProviderEventId, + recurrenceId: "2026-07-21T09:00:00-06:00", + cancelled: false, + title: "Moved", + }); + const command = await editAllCommand(master, { + title: "New", + recurrence: { kind: "preserve" }, + }); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerSeries("Old", "etag-1", weekly4); + writer.instancePatchError = new ProviderWriteError( + "permanentProviderError", + "Google rejected the write", + ); + + const result = await executeProviderSeriesUpdate( + deps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect( + writer.fetchCalls.some( + (call) => call.providerEventId === "g-inst-override", + ), + ).toBe(true); + expect( + await events.findById(tenantId, principalId, override._id), + ).toBeNull(); + const starts = (await masterOccurrences(master._id)).map((o) => + (o["startAt"] as Date).toISOString(), + ); + expect(starts).toContain("2026-07-21T15:00:00.000Z"); + }); + + it("converts a series to a single event, dropping every exception", async () => { + const { tenantId, principalId, calendar, master } = await seedMaster(); + await putException(master, { + providerEventId: "g-inst-cancelled" as ProviderEventId, + recurrenceId: "2026-07-28T09:00:00-06:00", + cancelled: true, + title: "Old", + }); + const command = await editAllCommand(master, { + title: "Just once", + recurrence: { kind: "single" }, + }); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerSeries("Old", "etag-1", weekly4); + + const result = await executeProviderSeriesUpdate( + deps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + // The provider write removes recurrence. + expect(writer.patchCalls[0]!.recurrence).toEqual({ kind: "single" }); + // Convert-to-single also cancels every discarded exception at the provider + // (including former cancellations) so a later pull cannot resurrect them. + expect(writer.deleteCalls).toEqual([ + expect.objectContaining({ + providerEventId: "g-inst-cancelled" as ProviderEventId, + }), + ]); + const stored = await events.findById(tenantId, principalId, master._id); + expect(stored?.recurrence).toEqual({ kind: "single" }); + // No exceptions survive a conversion to a single event, and the master + // projects exactly one occurrence. + expect( + await events.findSeriesExceptions(tenantId, principalId, master._id), + ).toHaveLength(0); + expect(await masterOccurrences(master._id)).toHaveLength(1); + }); + + it("converges when executed twice (idempotent retry)", async () => { + const { tenantId, principalId, calendar, master } = await seedMaster(); + const command = await editAllCommand(master, { + title: "New", + recurrence: { kind: "series", rules: weekly2 }, + }); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerSeries("Old", "etag-1", weekly4); + writer.patchResult = { + providerEventId: "g-evt-1" as ProviderEventId, + providerVersion: "etag-2", + }; + + await executeProviderSeriesUpdate( + deps(writer), + command, + master, + calendar, + now, + ); + // The provider now reflects the landed edit, so a retry is a replay. + writer.fetched = providerSeries("New", "etag-2", weekly2); + const second = await executeProviderSeriesUpdate( + deps(writer), + command, + master, + calendar, + now, + ); + + expect(second.outcome.state).toBe("confirmed"); + // Only the first attempt wrote; the retry recognized the replay. + expect(writer.patchCalls).toHaveLength(1); + const owned = await mongo.db + .collection(SYNC_COLLECTIONS.events) + .find({ tenantId, principalId, calendarId: calendar._id }) + .toArray(); + expect(owned).toHaveLength(1); + expect(await masterOccurrences(master._id)).toHaveLength(2); + }); + + it("leaves the command pending on a transient patch failure", async () => { + const { calendar, master } = await seedMaster(); + const command = await editAllCommand(master, { + title: "New", + recurrence: { kind: "preserve" }, + }); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerSeries("Old", "etag-1", weekly4); + writer.patchError = new ProviderWriteError("transient", "blip"); + + const result = await executeProviderSeriesUpdate( + deps(writer), + command, + master, + calendar, + now, + ); + + expect(result.outcome.state).toBe("pending"); + }); +}); diff --git a/packages/sync/src/domain/provider-command.series-update.ts b/packages/sync/src/domain/provider-command.series-update.ts new file mode 100644 index 0000000000..87fa139683 --- /dev/null +++ b/packages/sync/src/domain/provider-command.series-update.ts @@ -0,0 +1,365 @@ +import { type Attendee } from "@core/types/event-attendance.contracts"; +import { + type ProviderEventVersion, + type SyncEventContent, +} from "@core/types/sync/event.contracts"; +import { + type ConnectionId, + type ProviderEventId, +} from "@core/types/sync/identity.contracts"; +import { + mergeAttendees, + resolveUpdateContent, + resolveUpdateSchedule, +} from "@sync/domain/merge-update-content"; +import { occurrenceScheduleAfterSeriesEdit } from "@sync/domain/occurrence-projection"; +import { type ProviderMutationDeps } from "@sync/domain/provider-command.deps"; +import { + intendedSeriesRecurrence, + matchesIntendedEdit, + patchExpectedVersion, + storedSeriesRecurrence, +} from "@sync/domain/provider-command.intent-match"; +import { + failCommand, + organizerGuardFailure, + resolveFailedOverrideAlign, +} from "@sync/domain/provider-command.internal"; +import { + resolveAccessToken, + runProviderWrite, +} from "@sync/domain/provider-write-ladder"; +import { reprojectOccurrences } from "@sync/domain/reproject"; +import { + exceptionInstant, + partitionEditAllExceptions, +} from "@sync/domain/series-exception"; +import { type CommandRecord } from "@sync/storage/contracts/command.contracts"; +import { type EventRecord } from "@sync/storage/contracts/event.contracts"; +import { type ProviderCalendarRecord } from "@sync/storage/contracts/provider-calendar.contracts"; + +export async function executeProviderSeriesUpdate( + deps: ProviderMutationDeps, + command: CommandRecord, + master: EventRecord, + calendar: ProviderCalendarRecord, + now: () => Date, +): Promise { + if (command.input.kind !== "update") { + throw new Error("executeProviderSeriesUpdate requires an update command"); + } + if (!master.connectionId || !master.providerEventId) { + throw new Error("executeProviderSeriesUpdate requires a linked event"); + } + if (master.recurrence.kind !== "seriesMaster") { + throw new Error("executeProviderSeriesUpdate requires a series master"); + } + const { input } = command; + const connectionId = master.connectionId; + const providerEventId = master.providerEventId; + const intendedRecurrence = intendedSeriesRecurrence(input.recurrence, master); + + // Same organizer gate as the single-event path: a non-organizer guest-list + // replace fails typed before any provider call. + if (input.attendeesEdit === "replace") { + const guardFailure = await organizerGuardFailure( + deps, + command, + master, + connectionId, + ); + if (guardFailure) return guardFailure; + } + + const tokenResult = await resolveAccessToken(deps.custody, connectionId); + if (!tokenResult.ok) { + if (tokenResult.stop.kind === "pending") return command; + return failCommand(deps, command, tokenResult.stop.reason, connectionId); + } + const { accessToken } = tokenResult; + + const location = { + accessToken, + calendarId: calendar.providerCalendarId, + providerEventId, + }; + + // Fetch the master's current provider state to detect a replay and learn the + // version to commit. A cancellation read means the series no longer exists. + const fetchResult = await runProviderWrite(() => + deps.writer.fetchEvent(location), + ); + if (!fetchResult.ok) { + if (fetchResult.stop.kind === "pending") return command; + return failCommand(deps, command, fetchResult.stop.reason, connectionId); + } + const current = + fetchResult.value?.kind === "event" ? fetchResult.value : null; + if (!current) { + return failCommand(deps, command, "permanentProviderError", connectionId); + } + + let content = resolveUpdateContent( + master.content, + input.content, + current.content, + ); + const schedule = resolveUpdateSchedule(input.schedule, current.schedule); + // Guest membership merges against the freshly fetched master, mirroring the + // single-event path (see executeProviderUpdate). + const intendedAttendees = + input.attendeesEdit === "replace" && input.content + ? mergeAttendees(input.content.attendees, current.content.attendees) + : undefined; + if (intendedAttendees) { + content = { ...content, attendees: intendedAttendees }; + } + + // Replay: the provider already holds this series edit (rules included), so + // confirm at its version rather than writing again. + if ( + matchesIntendedEdit( + current, + content, + schedule, + intendedRecurrence, + intendedAttendees, + ) + ) { + return commitProviderSeriesUpdate( + deps, + command, + master, + content, + intendedAttendees, + current.providerVersion, + now, + { + accessToken, + calendarId: calendar.providerCalendarId, + connectionId, + }, + ); + } + + const patchResult = await runProviderWrite(() => + deps.writer.patchEvent({ + ...location, + expectedVersion: patchExpectedVersion( + command, + current, + master, + intendedAttendees !== undefined, + ), + content, + schedule, + recurrence: intendedRecurrence, + invitation: input.invitation, + ...(intendedAttendees ? { attendees: intendedAttendees } : {}), + }), + ); + if (!patchResult.ok) { + if (patchResult.stop.kind === "pending") return command; + return failCommand(deps, command, patchResult.stop.reason, connectionId); + } + const result = patchResult.value; + + return commitProviderSeriesUpdate( + deps, + command, + master, + content, + intendedAttendees, + result.providerVersion, + now, + { + accessToken, + calendarId: calendar.providerCalendarId, + connectionId, + }, + ); +} + +// Commit a provider series edit-all locally after the provider write lands. +// An edit-all discards per-instance content/time OVERRIDES (they revert to the +// edited series) but KEEPS cancelled tombstones, whose instants are excluded +// from the reprojected master so a deleted occurrence is not resurrected. A +// conversion to a single event drops every exception. +// +// Content/time overrides are aligned at the provider (patched to the edited +// series), then deleted locally BEFORE the master is replaced — the same +// crash-safety ordering the cloud path uses. Google does not drop instance +// overrides when the master is patched, so a later pull would otherwise +// resurrect stale override fields. Patching (not deleting) keeps the +// occurrence confirmed: Google's events.delete on an instance cancels that +// date, and a later pull would tombstone it out of the series. A +// convert-to-single edit still deletes discarded exceptions at the provider +// (they are no longer series members). Clearing locals first also closes the +// convert-to-single retry hole: a retry that read the converted single master +// would take the single-event path, which never cleans exceptions. Gating the +// kept/discarded split on the command's immutable recurrence intent keeps a +// retry classifying identically. A false from replaceExisting means the +// master vanished mid-flight, so leave the command pending rather than +// confirm a gone series. +async function commitProviderSeriesUpdate( + deps: ProviderMutationDeps, + command: CommandRecord, + master: EventRecord, + content: SyncEventContent, + // Present when this edit-all replaced the guest list: the override-align + // patches carry the same merged membership, so a reverted override does not + // keep a stale guest list Google would otherwise leave on it. + intendedAttendees: readonly Attendee[] | undefined, + providerVersion: string, + now: () => Date, + provider: { + accessToken: string; + calendarId: string; + connectionId: ConnectionId; + }, +): Promise { + if (command.input.kind !== "update") { + throw new Error("commitProviderSeriesUpdate requires an update command"); + } + const { input } = command; + const convertsToSingle = input.recurrence.kind === "single"; + const exceptions = await deps.events.findSeriesExceptions( + command.tenantId, + command.principalId, + master._id, + ); + const { kept, discarded } = partitionEditAllExceptions( + exceptions, + convertsToSingle, + ); + + // Align or remove discarded overrides at Google first, then clear local + // copies. Kept cancelled tombstones are not touched at the provider. + for (const exception of discarded) { + const exceptionProviderEventId = exception.providerEventId; + if (exceptionProviderEventId) { + const alignResult = await runProviderWrite(async () => { + if (convertsToSingle) { + await deps.writer.deleteEvent({ + accessToken: provider.accessToken, + calendarId: provider.calendarId, + providerEventId: exceptionProviderEventId, + expectedVersion: null, + invitation: input.invitation, + }); + return; + } + await deps.writer.patchEvent({ + // Revert the override into the edited series without cancelling + // the occurrence. Delete would mark the instance cancelled at Google. + accessToken: provider.accessToken, + calendarId: provider.calendarId, + providerEventId: exceptionProviderEventId, + expectedVersion: null, + content, + schedule: occurrenceScheduleAfterSeriesEdit( + master.schedule, + resolveUpdateSchedule(input.schedule, master.schedule), + exceptionInstant(exception), + ), + recurrence: { kind: "instance" }, + invitation: input.invitation, + ...(intendedAttendees ? { attendees: intendedAttendees } : {}), + }); + }); + if (!alignResult.ok) { + if (alignResult.stop.kind === "pending") return command; + if (convertsToSingle) { + return failCommand( + deps, + command, + alignResult.stop.reason, + provider.connectionId, + ); + } + const stop = await resolveFailedOverrideAlign( + deps, + command, + provider, + exceptionProviderEventId, + alignResult.stop.reason, + ); + if (stop) return stop; + } + } + + await deps.occurrences.replaceForEvent( + exception._id, + exception.generation, + [], + ); + await deps.events.deleteById( + command.tenantId, + command.principalId, + exception._id, + ); + } + + const updated: EventRecord = { + ...master, + content, + schedule: input.schedule ?? master.schedule, + recurrence: storedSeriesRecurrence(input.recurrence, master), + providerVersion: providerVersion as ProviderEventVersion, + providerUpdatedAt: null, + deliveryState: "confirmed", + updatedAt: now(), + }; + const applied = await deps.events.replaceExisting(updated); + if (!applied) return command; + await reprojectOccurrences( + deps.occurrences, + updated, + now, + kept.map(exceptionInstant), + ); + + const confirmed = await deps.commands.updateOutcome( + command.tenantId, + command.principalId, + command._id, + { + state: "confirmed", + providerEventId: master.providerEventId as ProviderEventId, + providerVersion: providerVersion as ProviderEventVersion, + }, + command.attemptCount, + ); + return confirmed ?? command; +} + +// --------------------------------------------------------------------------- +// Provider-linked recurring scopes "this" and "thisAndFollowing". +// +// A this/thisAndFollowing scope operates on ONE instance of a provider series, +// which — unlike a cloud series exception — has no id of its own until +// resolved via writer.fetchInstanceAt. Every executor below: resolves the +// instance (or, for a split, patches the master directly), applies the same +// replay-safe fetch-then-compare pattern executeProviderUpdate/ +// executeProviderSeriesUpdate already use, and commits locally through +// upsertException/reprojectMaster — the exact local-commit shape the cloud +// path's updateCloudOccurrence/deleteCloudOccurrence/*SeriesFollowing already +// use (series-exception.ts), so a provider-linked and a cloud-only +// series converge to the same on-disk shape. +// +// Known deferred gap: un-cancelling a provider instance (a scope-"this" edit +// of an instance the provider already reports as cancelled) is not +// implemented — it fails with permanentProviderError rather than silently +// no-op'ing. Restoring a cancelled Google instance to "confirmed" is a +// distinct provider operation this slice does not need for the common +// edit/delete-a-live-instance path. +// --------------------------------------------------------------------------- + +// Apply a Compass-initiated scope-"this" edit to one occurrence of a +// provider-linked series: resolve the instance's own provider identity, then +// patch IT (never the master) — mirrors executeProviderUpdate's replay-safe +// fetch-then-compare, but against the resolved instance's location. +// +// Provider-managed events are single-only today, so this occurrence path is +// unreachable for them; managed customizations live in the single-event update +// path instead. diff --git a/packages/sync/src/domain/provider-command.service.db.test.ts b/packages/sync/src/domain/provider-command.service.db.test.ts deleted file mode 100644 index ad7bd44c17..0000000000 --- a/packages/sync/src/domain/provider-command.service.db.test.ts +++ /dev/null @@ -1,4145 +0,0 @@ -import { faker } from "@faker-js/faker"; -import { type Document, type Filter } from "mongodb"; -import { - type DateTime, - type EventId, - type TimeZone, -} from "@core/types/domain-primitives"; -import { type Attendee } from "@core/types/event-attendance.contracts"; -import { type EventColorSlot } from "@core/types/event-color.contracts"; -import { type RecurrenceEdit } from "@core/types/event-command.contracts"; -import { type SyncCommandInput } from "@core/types/sync/command.contracts"; -import { type ProviderEventVersion } from "@core/types/sync/event.contracts"; -import { - type ConnectionId, - type IdempotencyKey, - type PrincipalId, - type ProviderCalendarSourceId, - type ProviderEventId, - type TenantId, -} from "@core/types/sync/identity.contracts"; -import { - bindCommandRepos, - COMMAND_NOW, - FakeProviderEventWriter, - failingTokenSource, - newCommandIds, - providerDeleteDeps, - providerMutationDeps, - RevokedAuthAdapter, - seedCommandCalendar, - seedLinkedEvent, - storeCommandCredential, - stubConnectionLookup, - TEST_CREDENTIAL_ENCRYPTION_KEY, - tokenSource, -} from "@sync/__tests__/helpers/command-scenario"; -import { setupSyncStorage } from "@sync/__tests__/helpers/storage"; -import { CredentialCustody } from "@sync/credentials/credential-custody.service"; -import { truncateRulesBefore } from "@sync/domain/occurrence-projection"; -import { - executeProviderCreate, - executeProviderDelete, - executeProviderOccurrenceDelete, - executeProviderOccurrenceUpdate, - executeProviderRsvp, - executeProviderSeriesFollowingDelete, - executeProviderSeriesFollowingUpdate, - executeProviderSeriesUpdate, - executeProviderUpdate, - type ProviderConnectionLookup, -} from "@sync/domain/provider-command.service"; -import { reprojectOccurrences } from "@sync/domain/reproject"; -import { ProviderAuthError } from "@sync/providers/provider-auth.port"; -import { type ProviderEvent } from "@sync/providers/provider-event.port"; -import { - type ProviderEventWriter, - ProviderWriteError, -} from "@sync/providers/provider-event-writer.port"; -import { findSafetyCanaryHit } from "@sync/safety/safety-canary"; -import { SYNC_COLLECTIONS } from "@sync/storage/collections"; -import { type EventRecord } from "@sync/storage/contracts/event.contracts"; -import { type ProviderCalendarRecord } from "@sync/storage/contracts/provider-calendar.contracts"; -import { type CommandRepository } from "@sync/storage/repositories/command.repository"; -import { type CredentialRepository } from "@sync/storage/repositories/credential.repository"; -import { type DeletionMarkerRepository } from "@sync/storage/repositories/deletion-marker.repository"; -import { type EventRepository } from "@sync/storage/repositories/event.repository"; -import { type EventOccurrenceRepository } from "@sync/storage/repositories/event-occurrence.repository"; -import { type ProviderCalendarRepository } from "@sync/storage/repositories/provider-calendar.repository"; -import { type SyncResourceRepository } from "@sync/storage/repositories/sync-resource.repository"; -import { type SyncMongoService } from "@sync/storage/sync-mongo.service"; -import { beforeEach, describe, expect, it } from "bun:test"; - -const storage = setupSyncStorage(import.meta.url); -const repos = bindCommandRepos(storage); -const objectId = () => faker.database.mongodbObjectId(); -const now = COMMAND_NOW; - -let mongo: SyncMongoService; -let commands: CommandRepository; -let events: EventRepository; -let occurrences: EventOccurrenceRepository; -let resources: SyncResourceRepository; -let calendars: ProviderCalendarRepository; -let markers: DeletionMarkerRepository; -let credentials: CredentialRepository; - -beforeEach(() => { - mongo = repos.mongo; - commands = repos.commands; - events = repos.events; - occurrences = repos.occurrences; - resources = repos.resources; - calendars = repos.calendars; - markers = repos.markers; - credentials = repos.credentials; -}); - -describe("executeProviderCreate", () => { - const createInput = ( - calendarId: string, - invitation = "none", - ): SyncCommandInput => - ({ - kind: "create", - calendarId, - invitation, - content: { - title: "Sync me", - description: "", - location: null, - organizer: null, - attendees: [], - conference: null, - }, - schedule: { - kind: "timed", - start: "2026-07-14T09:00:00-06:00", - end: "2026-07-14T10:00:00-06:00", - timeZone: "America/Denver", - }, - recurrence: { kind: "single" }, - }) as unknown as SyncCommandInput; - - // Seed a pending create command plus its target provider calendar, and return - // both with the fake dependencies wired up. - const seed = async (invitation = "none") => { - const ids = newCommandIds(); - const calendar = await seedCommandCalendar(calendars, ids, { - providerCalendarId: - "primary@group.calendar.google.com" as ProviderCalendarSourceId, - }); - const { record: command } = await commands.submit({ - tenantId: ids.tenantId, - principalId: ids.principalId, - idempotencyKey: ids.idempotencyKey, - eventId: ids.eventId, - input: createInput(calendar._id, invitation), - expectedVersion: null, - }); - return { - tenantId: ids.tenantId, - principalId: ids.principalId, - calendar, - command, - }; - }; - - it("writes to the provider, commits its identity, and confirms", async () => { - const { tenantId, principalId, calendar, command } = await seed(); - const writer = new FakeProviderEventWriter(); - - const result = await executeProviderCreate( - { - commands, - events, - occurrences, - resources, - connections: stubConnectionLookup(), - writer, - custody: tokenSource(), - }, - command, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect( - result.outcome.state === "confirmed" && result.outcome.providerEventId, - ).toBe("g-evt-1" as ProviderEventId); - - // Called with the raw provider calendar id and the deterministic event id. - expect(writer.calls).toHaveLength(1); - expect(writer.calls[0]!.calendarId).toBe(calendar.providerCalendarId); - expect(writer.calls[0]!.providerEventId).toBe(command.eventId); - - const stored = await events.findById( - tenantId, - principalId, - command.eventId, - ); - expect(stored?.connectionId).toBe(calendar.connectionId); - expect(stored?.providerEventId).toBe("g-evt-1" as ProviderEventId); - expect(stored?.providerVersion).toBe("etag-1" as ProviderEventVersion); - expect(stored?.deliveryState).toBe("confirmed"); - expect(stored?.providerMetadata).toBeNull(); - - // The provider-linked event is projected into the read model. - const occ = await mongo.db - .collection(SYNC_COLLECTIONS.eventOccurrences) - .find({ eventId: command.eventId }) - .toArray(); - expect(occ.map((o) => (o["startAt"] as Date).toISOString())).toEqual([ - "2026-07-14T15:00:00.000Z", - ]); - }); - - it("stores iCalUID from the write result on create", async () => { - const { tenantId, principalId, calendar, command } = await seed(); - const writer = new FakeProviderEventWriter(); - writer.result = { - providerEventId: "g-evt-1" as ProviderEventId, - providerVersion: "etag-1", - icalUid: "g-evt-1@google.com", - }; - - await executeProviderCreate( - { - commands, - events, - occurrences, - resources, - connections: stubConnectionLookup(), - writer, - custody: tokenSource(), - }, - command, - calendar, - now, - ); - - const stored = await events.findById( - tenantId, - principalId, - command.eventId, - ); - expect(stored?.providerMetadata).toEqual({ iCalUID: "g-evt-1@google.com" }); - }); - - it("stores the Meet URL Google minted on create, not the command's null", async () => { - const { tenantId, principalId, calendar, command } = await seed(); - const writer = new FakeProviderEventWriter(); - writer.result = { - providerEventId: "g-evt-1" as ProviderEventId, - providerVersion: "etag-1", - conference: { - url: "https://meet.google.com/abc-defg-hij", - label: "Google Meet", - }, - }; - - await executeProviderCreate( - { - commands, - events, - occurrences, - resources, - connections: stubConnectionLookup(), - writer, - custody: tokenSource(), - }, - command, - calendar, - now, - ); - - const stored = await events.findById( - tenantId, - principalId, - command.eventId, - ); - expect( - command.input.kind === "create" && command.input.content.conference, - ).toBe(null); - expect(stored?.content.conference).toEqual({ - url: "https://meet.google.com/abc-defg-hij", - label: "Google Meet", - }); - }); - - it("passes the caller's invitation intent through to the writer", async () => { - const { calendar, command } = await seed("all"); - const writer = new FakeProviderEventWriter(); - - await executeProviderCreate( - { - commands, - events, - occurrences, - resources, - connections: stubConnectionLookup(), - writer, - custody: tokenSource(), - }, - command, - calendar, - now, - ); - - expect(writer.calls[0]!.invitation).toBe("all"); - }); - - it("converges on one event when executed twice (idempotent write)", async () => { - const { tenantId, principalId, calendar, command } = await seed(); - const writer = new FakeProviderEventWriter(); - const deps = { - commands, - events, - occurrences, - resources, - connections: stubConnectionLookup(), - writer, - custody: tokenSource(), - }; - - await executeProviderCreate(deps, command, calendar, now); - await executeProviderCreate(deps, command, calendar, now); - - const owned = await mongo.db - .collection(SYNC_COLLECTIONS.events) - .find({ tenantId, principalId, calendarId: calendar._id }) - .toArray(); - expect(owned).toHaveLength(1); - }); - - it("projects a create at the calendar's active generation, not zero", async () => { - // 2026-08-01: a repaired calendar reads at generation 1, but creates - // hardcoded their occurrences to generation 0, so a new event saved - // successfully to Google and was then invisible in Compass. That was - // meant to self-heal on the next incremental pull; when the sweeps froze, - // the window stayed open for a day. - const { tenantId, principalId, calendar, command } = await seed(); - const resource = await resources.ensure({ - tenantId, - principalId, - connectionId: calendar.connectionId, - resourceKind: "events", - calendarId: calendar._id, - }); - await resources.startNewGeneration(tenantId, principalId, resource._id); - await resources.activateGeneration(tenantId, principalId, resource._id, 1); - - await executeProviderCreate( - providerMutationDeps( - { commands, events, occurrences, resources }, - new FakeProviderEventWriter(), - ), - command, - calendar, - now, - ); - - // Visible to a read at the generation the calendar actually serves. - const atActive = await mongo.db - .collection(SYNC_COLLECTIONS.events) - .find({ tenantId, principalId, calendarId: calendar._id, generation: 1 }) - .toArray(); - expect(atActive).toHaveLength(1); - expect(String(atActive[0]?.["_id"])).toBe(command.eventId); - }); - - it("leaves the command pending on a transient write failure", async () => { - const { tenantId, principalId, calendar, command } = await seed(); - const writer = new FakeProviderEventWriter(); - writer.error = new ProviderWriteError("transient", "network blip"); - - const result = await executeProviderCreate( - { - commands, - events, - occurrences, - resources, - connections: stubConnectionLookup(), - writer, - custody: tokenSource(), - }, - command, - calendar, - now, - ); - - expect(result.outcome.state).toBe("pending"); - expect( - await events.findById(tenantId, principalId, command.eventId), - ).toBeNull(); - }); - - it("fails the command on a terminal write error", async () => { - const { tenantId, principalId, calendar, command } = await seed(); - const writer = new FakeProviderEventWriter(); - writer.error = new ProviderWriteError("readOnlyCalendar", "read only"); - - const result = await executeProviderCreate( - { - commands, - events, - occurrences, - resources, - connections: stubConnectionLookup(), - writer, - custody: tokenSource(), - }, - command, - calendar, - now, - ); - - expect(result.outcome.state).toBe("failed"); - expect( - result.outcome.state === "failed" && result.outcome.failureReason, - ).toBe("readOnlyCalendar"); - expect( - await events.findById(tenantId, principalId, command.eventId), - ).toBeNull(); - }); - - it("fails the command when the credential is revoked, without writing", async () => { - const { calendar, command } = await seed(); - const writer = new FakeProviderEventWriter(); - await storeCommandCredential(credentials, calendar.connectionId); - const custody = new CredentialCustody( - credentials, - () => - new RevokedAuthAdapter({ - refreshError: new ProviderAuthError( - "authorizationRevoked", - "revoked", - ), - }), - undefined, - undefined, - TEST_CREDENTIAL_ENCRYPTION_KEY, - ); - - const result = await executeProviderCreate( - providerMutationDeps( - { commands, events, occurrences, resources }, - writer, - { custody }, - ), - command, - calendar, - now, - ); - - expect(result.outcome.state).toBe("failed"); - expect( - result.outcome.state === "failed" && result.outcome.failureReason, - ).toBe("authorizationRevoked"); - expect(writer.calls).toHaveLength(0); - expect( - await credentials.findByConnection(calendar.connectionId), - ).toBeNull(); - }); - - it("leaves the command pending on a transient refresh failure", async () => { - const { calendar, command } = await seed(); - const writer = new FakeProviderEventWriter(); - - const result = await executeProviderCreate( - providerMutationDeps( - { commands, events, occurrences, resources }, - writer, - { - custody: failingTokenSource( - new ProviderAuthError("refreshFailed", "temporary"), - ), - }, - ), - command, - calendar, - now, - ); - - expect(result.outcome.state).toBe("pending"); - expect(writer.calls).toHaveLength(0); - }); -}); - -describe("executeProviderUpdate", () => { - const schedule = { - kind: "timed" as const, - start: "2026-07-14T09:00:00-06:00" as DateTime, - end: "2026-07-14T10:00:00-06:00" as DateTime, - timeZone: "America/Denver" as TimeZone, - }; - const content = (title: string) => ({ - title, - description: "", - location: null, - organizer: null, - attendees: [], - conference: null, - }); - const providerEvent = (title: string, version: string): ProviderEvent => ({ - kind: "event", - providerEventId: "g-evt-1" as ProviderEventId, - providerVersion: version, - providerUpdatedAt: null, - content: content(title), - schedule, - busy: true, - recurrence: { kind: "single" }, - }); - - // Seed a provider-linked event plus an update command that renames it to - // "New". The provider currently holds "Old" at etag-1. - const seed = async () => { - const ids = newCommandIds(); - const calendar = await seedCommandCalendar(calendars, ids); - const event = await seedLinkedEvent(events, { - ids, - calendarId: calendar._id, - content: content("Old"), - schedule, - recurrence: { kind: "single" }, - now: now(), - }); - const { record: command } = await commands.submit({ - tenantId: ids.tenantId, - principalId: ids.principalId, - idempotencyKey: ids.idempotencyKey, - eventId: event._id, - input: { - kind: "update", - invitation: "all", - content: content("New"), - schedule, - recurrence: { kind: "preserve" }, - scope: "all", - } as unknown as SyncCommandInput, - expectedVersion: "etag-1" as never, - }); - return { - tenantId: ids.tenantId, - principalId: ids.principalId, - calendar, - event, - command, - }; - }; - - it("patches the provider and commits the new version and content", async () => { - const { tenantId, principalId, calendar, event, command } = await seed(); - const writer = new FakeProviderEventWriter(); - // The provider still holds the old content, so this is a real edit. - writer.fetched = providerEvent("Old", "etag-1"); - - const result = await executeProviderUpdate( - { - commands, - events, - occurrences, - resources, - connections: stubConnectionLookup(), - writer, - custody: tokenSource(), - }, - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls).toHaveLength(1); - expect(writer.patchCalls[0]!.expectedVersion).toBe("etag-1"); - expect(writer.patchCalls[0]!.invitation).toBe("all"); - const stored = await events.findById(tenantId, principalId, event._id); - expect(stored?.content.title).toBe("New"); - expect(stored?.providerVersion).toBe("etag-2" as ProviderEventVersion); - - // The occurrence projection is rebuilt with the edited title. - const occ = await mongo.db - .collection(SYNC_COLLECTIONS.eventOccurrences) - .find({ eventId: event._id }) - .toArray(); - expect(occ).toHaveLength(1); - expect(occ[0]?.["title"]).toBe("New"); - }); - - it("confirms without re-patching when the edit already landed (replay)", async () => { - const { tenantId, principalId, calendar, event, command } = await seed(); - const writer = new FakeProviderEventWriter(); - // The provider already holds this command's intended content at a new - // version — a prior attempt landed before the crash. - writer.fetched = providerEvent("New", "etag-2"); - - const result = await executeProviderUpdate( - { - commands, - events, - occurrences, - resources, - connections: stubConnectionLookup(), - writer, - custody: tokenSource(), - }, - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - // No second write — the replay is recognized from the fetch. - expect(writer.patchCalls).toHaveLength(0); - const stored = await events.findById(tenantId, principalId, event._id); - expect(stored?.providerVersion).toBe("etag-2" as ProviderEventVersion); - }); - - it("recognizes a replay even when read-reflected fields drifted", async () => { - const { calendar, event, command } = await seed(); - const writer = new FakeProviderEventWriter(); - // The written fields (title/description/location/schedule) match this - // command's edit, but an attendee RSVP'd after our patch landed — a field - // the patch never writes. This must still count as a replay, not a false - // conflict on an edit that already succeeded. - writer.fetched = { - kind: "event", - providerEventId: "g-evt-1" as ProviderEventId, - providerVersion: "etag-2", - providerUpdatedAt: null, - content: { - title: "New", - description: "", - location: null, - organizer: null, - attendees: [ - { - email: "guest@example.com", - displayName: null, - responseStatus: "accepted", - }, - ], - conference: null, - }, - schedule, - busy: true, - recurrence: { kind: "single" }, - }; - - const result = await executeProviderUpdate( - { - commands, - events, - occurrences, - resources, - connections: stubConnectionLookup(), - writer, - custody: tokenSource(), - }, - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls).toHaveLength(0); - }); - - it("patches against the fresh version when only the provider's version key drifted", async () => { - const { tenantId, principalId, calendar, event, command } = await seed(); - const writer = new FakeProviderEventWriter(); - // The provider rotated the version on its own (Exchange rewrites a change - // key seconds after a create) but still holds exactly what Compass stored, - // so nobody edited it elsewhere and the stale etag-1 must not block the - // edit. - writer.fetched = providerEvent("Old", "etag-1-rotated"); - - const result = await executeProviderUpdate( - { - commands, - events, - occurrences, - resources, - connections: stubConnectionLookup(), - writer, - custody: tokenSource(), - }, - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls).toHaveLength(1); - expect(writer.patchCalls[0]!.expectedVersion).toBe("etag-1-rotated"); - const stored = await events.findById(tenantId, principalId, event._id); - expect(stored?.content.title).toBe("New"); - expect(stored?.providerVersion).toBe("etag-2" as ProviderEventVersion); - }); - - it("fails with a conflict on a genuine concurrent external edit", async () => { - const { calendar, event, command } = await seed(); - const writer = new FakeProviderEventWriter(); - // The provider was edited externally (different content, and the - // conditional patch is rejected). - writer.fetched = providerEvent("Someone else's edit", "etag-9"); - writer.patchError = new ProviderWriteError("versionConflict", "stale"); - - const result = await executeProviderUpdate( - { - commands, - events, - occurrences, - resources, - connections: stubConnectionLookup(), - writer, - custody: tokenSource(), - }, - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("failed"); - expect( - result.outcome.state === "failed" && result.outcome.failureReason, - ).toBe("versionConflict"); - // The drifted version is NOT adopted when the content changed too: the - // patch stays conditioned on the stale version so it cannot overwrite - // the external edit. - expect(writer.patchCalls[0]!.expectedVersion).toBe("etag-1"); - }); - - it("fails when the provider event no longer exists", async () => { - const { calendar, event, command } = await seed(); - const writer = new FakeProviderEventWriter(); - writer.fetched = null; - - const result = await executeProviderUpdate( - { - commands, - events, - occurrences, - resources, - connections: stubConnectionLookup(), - writer, - custody: tokenSource(), - }, - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("failed"); - expect( - result.outcome.state === "failed" && result.outcome.failureReason, - ).toBe("permanentProviderError"); - expect(writer.patchCalls).toHaveLength(0); - }); - - it("leaves the command pending on a transient patch failure", async () => { - const { calendar, event, command } = await seed(); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerEvent("Old", "etag-1"); - writer.patchError = new ProviderWriteError("transient", "blip"); - - const result = await executeProviderUpdate( - { - commands, - events, - occurrences, - resources, - connections: stubConnectionLookup(), - writer, - custody: tokenSource(), - }, - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("pending"); - }); - - it("fails without touching the provider when the credential is revoked", async () => { - const { calendar, event, command } = await seed(); - const writer = new FakeProviderEventWriter(); - - const result = await executeProviderUpdate( - providerMutationDeps( - { commands, events, occurrences, resources }, - writer, - { - custody: failingTokenSource( - new ProviderAuthError("authorizationRevoked", "revoked"), - ), - }, - ), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("failed"); - expect(writer.fetchCalls).toHaveLength(0); - expect(writer.patchCalls).toHaveLength(0); - }); - - it("discards the credential when a writer 401 classifies as authorizationRevoked", async () => { - const { calendar, event, command } = await seed(); - await storeCommandCredential(credentials, calendar.connectionId, { - token: "still-cached", - expiresAt: new Date("2099-01-01T00:00:00Z"), - }); - const custody = new CredentialCustody( - credentials, - () => new RevokedAuthAdapter(), - undefined, - undefined, - TEST_CREDENTIAL_ENCRYPTION_KEY, - ); - const writer = new FakeProviderEventWriter(); - writer.fetchError = new ProviderWriteError( - "authorizationRevoked", - "token rejected", - ); - - const result = await executeProviderUpdate( - providerMutationDeps( - { commands, events, occurrences, resources }, - writer, - { custody }, - ), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("failed"); - expect( - result.outcome.state === "failed" && result.outcome.failureReason, - ).toBe("authorizationRevoked"); - expect( - await credentials.findByConnection(calendar.connectionId), - ).toBeNull(); - }); -}); - -describe("executeProviderUpdate on provider-managed events", () => { - const schedule = { - kind: "timed" as const, - start: "2026-07-14T09:00:00-06:00" as DateTime, - end: "2026-07-14T10:00:00-06:00" as DateTime, - timeZone: "America/Denver" as TimeZone, - }; - const content = (title: string, extras: Record = {}) => ({ - title, - description: "", - location: null, - organizer: null, - attendees: [], - conference: null, - ...extras, - }); - const providerEvent = ( - title: string, - version: string, - extras: Partial = {}, - ): ProviderEvent => ({ - kind: "event", - providerEventId: "g-evt-1" as ProviderEventId, - providerVersion: version, - providerUpdatedAt: null, - content: content(title), - schedule, - busy: true, - recurrence: { kind: "single" }, - providerManaged: true, - ...extras, - }); - - const seedManaged = async ( - inputOverrides: { - commandTitle?: string; - commandSchedule?: typeof schedule; - commandContent?: Record; - eventCustomizations?: EventRecord["customizations"]; - } = {}, - ) => { - const ids = newCommandIds(); - const calendar = await seedCommandCalendar(calendars, ids); - const event = await seedLinkedEvent(events, { - ids, - calendarId: calendar._id, - content: content("Provider title"), - schedule, - recurrence: { kind: "single" }, - now: now(), - }); - if (inputOverrides.eventCustomizations !== undefined) { - await events.replaceExisting({ - ...event, - customizations: inputOverrides.eventCustomizations, - }); - } - const commandTitle = inputOverrides.commandTitle ?? "Compass title"; - const { record: command } = await commands.submit({ - tenantId: ids.tenantId, - principalId: ids.principalId, - idempotencyKey: ids.idempotencyKey, - eventId: event._id, - input: { - kind: "update", - invitation: "none", - content: { - ...content(commandTitle), - ...inputOverrides.commandContent, - }, - schedule: inputOverrides.commandSchedule ?? schedule, - recurrence: { kind: "preserve" }, - scope: "all", - } as unknown as SyncCommandInput, - expectedVersion: "etag-1" as never, - }); - const storedEvent = await events.findById( - ids.tenantId, - ids.principalId, - event._id, - ); - if (!storedEvent) throw new Error("seed failed"); - return { - tenantId: ids.tenantId, - principalId: ids.principalId, - calendar, - event: storedEvent, - command, - }; - }; - - it("stores a title customization without calling patchEvent", async () => { - const { tenantId, principalId, calendar, event, command } = - await seedManaged(); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerEvent("Provider title", "etag-1"); - - const result = await executeProviderUpdate( - { - commands, - events, - occurrences, - resources, - connections: stubConnectionLookup(), - writer, - custody: tokenSource(), - }, - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls).toHaveLength(0); - const stored = await events.findById(tenantId, principalId, event._id); - expect(stored?.content.title).toBe("Provider title"); - expect(stored?.customizations).toEqual({ title: "Compass title" }); - }); - - it("patches color with providerManaged and confirms at the returned version", async () => { - const { tenantId, principalId, calendar, event, command } = - await seedManaged({ - commandContent: { color: "coral" }, - }); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerEvent("Provider title", "etag-1"); - - const result = await executeProviderUpdate( - { - commands, - events, - occurrences, - resources, - connections: stubConnectionLookup(), - writer, - custody: tokenSource(), - }, - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls).toHaveLength(1); - expect(writer.patchCalls[0]!.providerManaged).toBe(true); - const stored = await events.findById(tenantId, principalId, event._id); - expect(stored?.content.color).toBe("coral"); - expect(stored?.providerVersion).toBe("etag-2" as ProviderEventVersion); - }); - - it("fails schedule edits as unsupportedCapability without patching", async () => { - const movedSchedule = { - ...schedule, - start: "2026-07-14T10:00:00-06:00" as DateTime, - end: "2026-07-14T11:00:00-06:00" as DateTime, - }; - const { calendar, event, command } = await seedManaged({ - commandSchedule: movedSchedule, - }); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerEvent("Provider title", "etag-1"); - - const result = await executeProviderUpdate( - { - commands, - events, - occurrences, - resources, - connections: stubConnectionLookup(), - writer, - custody: tokenSource(), - }, - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("failed"); - expect( - result.outcome.state === "failed" && result.outcome.failureReason, - ).toBe("unsupportedCapability"); - expect(writer.patchCalls).toHaveLength(0); - }); - - it("clears customizations when the title matches the provider again", async () => { - const { tenantId, principalId, calendar, event, command } = - await seedManaged({ - commandTitle: "Provider title", - eventCustomizations: { title: "Old overlay" }, - }); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerEvent("Provider title", "etag-1"); - - const result = await executeProviderUpdate( - { - commands, - events, - occurrences, - resources, - connections: stubConnectionLookup(), - writer, - custody: tokenSource(), - }, - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls).toHaveLength(0); - const stored = await events.findById(tenantId, principalId, event._id); - expect(stored?.customizations).toBeNull(); - }); -}); - -describe("executeProviderDelete", () => { - const schedule = { - kind: "timed" as const, - start: "2026-07-14T09:00:00-06:00" as DateTime, - end: "2026-07-14T10:00:00-06:00" as DateTime, - timeZone: "America/Denver" as TimeZone, - }; - - // Seed a provider-linked event plus a delete command for it. - const seed = async () => { - const ids = newCommandIds(); - const calendar: ProviderCalendarRecord = { - _id: objectId() as never, - tenantId: ids.tenantId, - principalId: ids.principalId, - connectionId: ids.connectionId, - providerCalendarId: "primary@google.com" as never, - displayName: "Google", - color: null, - active: true, - primary: true, - accessRole: "owner", - capabilities: [], - createdAt: now(), - updatedAt: now(), - } as never; - const event = await seedLinkedEvent(events, { - ids, - calendarId: calendar._id, - content: { - title: "Doomed", - description: "", - location: null, - organizer: null, - attendees: [], - conference: null, - }, - schedule, - recurrence: { kind: "single" }, - now: now(), - }); - const { record: command } = await commands.submit({ - tenantId: ids.tenantId, - principalId: ids.principalId, - idempotencyKey: ids.idempotencyKey, - eventId: event._id, - input: { kind: "delete", invitation: "all", scope: "all" } as never, - expectedVersion: null, - }); - return { - tenantId: ids.tenantId, - principalId: ids.principalId, - calendar, - event, - command, - }; - }; - - it("deletes at the provider, tombstones, removes the local event, and confirms", async () => { - const { tenantId, principalId, calendar, event, command } = await seed(); - const writer = new FakeProviderEventWriter(); - // Project the event first so the delete has occurrences to clear. - await reprojectOccurrences(occurrences, event, now); - const occurrenceCount = () => - mongo.db - .collection(SYNC_COLLECTIONS.eventOccurrences) - .countDocuments({ eventId: event._id }); - expect(await occurrenceCount()).toBe(1); - - const result = await executeProviderDelete( - providerDeleteDeps( - { commands, events, occurrences, resources, markers }, - writer, - ), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.deleteCalls).toHaveLength(1); - expect(writer.deleteCalls[0]!.invitation).toBe("all"); - // Local content is gone, a content-free marker remains, occurrences cleared. - expect(await events.findById(tenantId, principalId, event._id)).toBeNull(); - expect(await occurrenceCount()).toBe(0); - expect( - await markers.exists( - calendar.connectionId, - event.calendarId, - "g-evt-1" as never, - ), - ).toBe(true); - }); - - it("confirms idempotently when the local event is already gone (replay)", async () => { - const { tenantId, principalId, calendar, event, command } = await seed(); - const writer = new FakeProviderEventWriter(); - // Simulate a prior attempt having already removed the local event. - await events.deleteById(tenantId, principalId, event._id); - - const result = await executeProviderDelete( - providerDeleteDeps( - { commands, events, occurrences, resources, markers }, - writer, - ), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - // Nothing to re-delete at the provider — the local absence proves it landed. - expect(writer.deleteCalls).toHaveLength(0); - }); - - it("cascades local series exceptions when deleting a provider series", async () => { - // Staging repro: Google-side instance override stays in Sync after the - // master is deleted with scope=all, and keeps resurfacing in range reads. - const tenantId = objectId() as TenantId; - const principalId = objectId() as PrincipalId; - const connectionId = objectId() as ConnectionId; - const calendar: ProviderCalendarRecord = { - _id: objectId() as never, - tenantId, - principalId, - connectionId, - providerCalendarId: "primary@google.com" as never, - displayName: "Google", - color: null, - active: true, - primary: true, - accessRole: "owner", - capabilities: [], - createdAt: now(), - updatedAt: now(), - } as never; - const masterId = objectId() as EventId; - await events.put({ - _id: masterId, - tenantId, - principalId, - origin: "provider", - calendarId: calendar._id, - clientEventId: null, - connectionId, - providerEventId: "g-series-1" as never, - providerVersion: "etag-1" as never, - providerUpdatedAt: null, - deliveryState: "confirmed", - providerMetadata: null, - content: { - title: "Weekly", - description: "", - location: null, - organizer: null, - attendees: [], - conference: null, - }, - schedule, - recurrence: { - kind: "seriesMaster", - rules: ["RRULE:FREQ=WEEKLY;COUNT=4"], - }, - lifecycleState: "active", - generation: 0, - createdAt: now(), - updatedAt: now(), - confirmedAt: now(), - } as never); - const master = await events.findById(tenantId, principalId, masterId); - if (!master) throw new Error("seed failed to read back the master"); - const exceptionId = objectId() as EventId; - await events.put({ - _id: exceptionId, - tenantId, - principalId, - origin: "provider", - calendarId: calendar._id, - clientEventId: null, - connectionId, - providerEventId: "g-inst-override" as never, - providerVersion: "etag-1" as never, - providerUpdatedAt: null, - deliveryState: "confirmed", - providerMetadata: null, - content: { - title: "Moved instance", - description: "", - location: null, - organizer: null, - attendees: [], - conference: null, - }, - schedule: { - kind: "timed", - start: "2026-07-21T11:00:00-06:00", - end: "2026-07-21T12:00:00-06:00", - timeZone: "America/Denver", - }, - recurrence: { - kind: "exception", - seriesId: masterId, - recurrenceId: "2026-07-21T09:00:00-06:00" as never, - cancelled: false, - }, - lifecycleState: "active", - generation: 0, - createdAt: now(), - updatedAt: now(), - confirmedAt: now(), - } as never); - const exception = await events.findById(tenantId, principalId, exceptionId); - if (!exception) throw new Error("seed failed to read back the exception"); - await reprojectOccurrences(occurrences, master, now); - await reprojectOccurrences(occurrences, exception, now); - const { record: command } = await commands.submit({ - tenantId, - principalId, - idempotencyKey: `idem-${objectId()}` as IdempotencyKey, - eventId: masterId as EventId, - input: { kind: "delete", invitation: "none", scope: "all" } as never, - expectedVersion: null, - }); - - const result = await executeProviderDelete( - providerDeleteDeps( - { commands, events, occurrences, resources, markers }, - new FakeProviderEventWriter(), - ), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(await events.findById(tenantId, principalId, masterId)).toBeNull(); - expect( - await events.findById(tenantId, principalId, exceptionId), - ).toBeNull(); - expect( - await events.findSeriesExceptions(tenantId, principalId, masterId), - ).toEqual([]); - expect( - await mongo.db - .collection(SYNC_COLLECTIONS.eventOccurrences) - .countDocuments({ eventId: { $in: [masterId, exceptionId] } }), - ).toBe(0); - }); - - it("clears leftover series exceptions on an already-gone master replay", async () => { - const tenantId = objectId() as TenantId; - const principalId = objectId() as PrincipalId; - const connectionId = objectId() as ConnectionId; - const calendar: ProviderCalendarRecord = { - _id: objectId() as never, - tenantId, - principalId, - connectionId, - providerCalendarId: "primary@google.com" as never, - displayName: "Google", - color: null, - active: true, - primary: true, - accessRole: "owner", - capabilities: [], - createdAt: now(), - updatedAt: now(), - } as never; - const masterId = objectId() as EventId; - const exceptionId = objectId() as EventId; - // Master already removed (prior attempt); orphan exception remains. - await events.put({ - _id: exceptionId, - tenantId, - principalId, - origin: "provider", - calendarId: calendar._id, - clientEventId: null, - connectionId, - providerEventId: "g-inst-orphan" as never, - providerVersion: "etag-1" as never, - providerUpdatedAt: null, - deliveryState: "confirmed", - providerMetadata: null, - content: { - title: "Orphan", - description: "", - location: null, - organizer: null, - attendees: [], - conference: null, - }, - schedule, - recurrence: { - kind: "exception", - seriesId: masterId, - recurrenceId: "2026-07-21T09:00:00-06:00" as never, - cancelled: false, - }, - lifecycleState: "active", - generation: 0, - createdAt: now(), - updatedAt: now(), - confirmedAt: now(), - } as never); - const ghostMaster = { - _id: masterId, - tenantId, - principalId, - origin: "provider", - calendarId: calendar._id, - clientEventId: null, - connectionId, - providerEventId: "g-series-1" as never, - providerVersion: "etag-1" as never, - providerUpdatedAt: null, - deliveryState: "confirmed", - providerMetadata: null, - content: { - title: "Weekly", - description: "", - location: null, - organizer: null, - attendees: [], - conference: null, - }, - schedule, - recurrence: { - kind: "seriesMaster", - rules: ["RRULE:FREQ=WEEKLY;COUNT=4"], - }, - lifecycleState: "active", - generation: 0, - createdAt: now(), - updatedAt: now(), - confirmedAt: now(), - } as EventRecord; - const { record: command } = await commands.submit({ - tenantId, - principalId, - idempotencyKey: `idem-${objectId()}` as IdempotencyKey, - eventId: masterId as EventId, - input: { kind: "delete", invitation: "none", scope: "all" } as never, - expectedVersion: null, - }); - - const result = await executeProviderDelete( - providerDeleteDeps( - { commands, events, occurrences, resources, markers }, - new FakeProviderEventWriter(), - ), - command, - ghostMaster, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect( - await events.findById(tenantId, principalId, exceptionId), - ).toBeNull(); - }); - - it("keeps the event deletionPending and stays pending on a transient failure", async () => { - const { tenantId, principalId, calendar, event, command } = await seed(); - const writer = new FakeProviderEventWriter(); - writer.deleteError = new ProviderWriteError("transient", "blip"); - - const result = await executeProviderDelete( - providerDeleteDeps( - { commands, events, occurrences, resources, markers }, - writer, - ), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("pending"); - const stored = await events.findById(tenantId, principalId, event._id); - expect(stored?.lifecycleState).toBe("deletionPending"); - }); - - it("reverts the event to active and fails on a terminal error", async () => { - const { tenantId, principalId, calendar, event, command } = await seed(); - const writer = new FakeProviderEventWriter(); - writer.deleteError = new ProviderWriteError( - "readOnlyCalendar", - "read only", - ); - - const result = await executeProviderDelete( - providerDeleteDeps( - { commands, events, occurrences, resources, markers }, - writer, - ), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("failed"); - expect( - result.outcome.state === "failed" && result.outcome.failureReason, - ).toBe("readOnlyCalendar"); - // The event is restored — a failed delete must not leave it "deleting". - const stored = await events.findById(tenantId, principalId, event._id); - expect(stored?.lifecycleState).toBe("active"); - }); - - it("reverts and fails without deleting when the credential is revoked", async () => { - const { tenantId, principalId, calendar, event, command } = await seed(); - const writer = new FakeProviderEventWriter(); - - const result = await executeProviderDelete( - providerDeleteDeps( - { commands, events, occurrences, resources, markers }, - writer, - { - custody: failingTokenSource( - new ProviderAuthError("authorizationRevoked", "revoked"), - ), - }, - ), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("failed"); - expect(writer.deleteCalls).toHaveLength(0); - const stored = await events.findById(tenantId, principalId, event._id); - expect(stored?.lifecycleState).toBe("active"); - }); -}); - -describe("executeProviderSeriesUpdate", () => { - // A weekly series of four occurrences starting 2026-07-14 09:00 Denver. - const schedule = { - kind: "timed" as const, - start: "2026-07-14T09:00:00-06:00" as DateTime, - end: "2026-07-14T10:00:00-06:00" as DateTime, - timeZone: "America/Denver" as TimeZone, - }; - const weekly4 = ["RRULE:FREQ=WEEKLY;COUNT=4"]; - const weekly2 = ["RRULE:FREQ=WEEKLY;COUNT=2"]; - const content = (title: string) => ({ - title, - description: "", - location: null, - organizer: null, - attendees: [], - conference: null, - }); - // The provider's view of the series master, with its current rules. - const providerSeries = ( - title: string, - version: string, - rules: readonly string[], - ): ProviderEvent => ({ - kind: "event", - providerEventId: "g-evt-1" as ProviderEventId, - providerVersion: version, - providerUpdatedAt: null, - content: content(title), - schedule, - busy: true, - recurrence: { kind: "seriesMaster", rules: [...rules] }, - }); - - // Seed a provider-linked series master ("Old", weekly x4 at etag-1). - const seedMaster = async () => { - const ids = newCommandIds(); - const calendar = await seedCommandCalendar(calendars, ids); - const master = await seedLinkedEvent(events, { - ids, - calendarId: calendar._id, - content: content("Old"), - schedule, - recurrence: { kind: "seriesMaster", rules: [...weekly4] }, - now: now(), - }); - await reprojectOccurrences(occurrences, master, now); - return { - tenantId: ids.tenantId, - principalId: ids.principalId, - calendar, - master, - }; - }; - - // An edit-all update command for the seeded master. - const editAllCommand = async ( - master: EventRecord, - edit: { - title: string; - recurrence: RecurrenceEdit; - schedule?: typeof schedule; - }, - ) => - ( - await commands.submit({ - tenantId: master.tenantId, - principalId: master.principalId, - idempotencyKey: `idem-${objectId()}` as IdempotencyKey, - eventId: master._id, - input: { - kind: "update", - invitation: "all", - content: content(edit.title), - schedule: edit.schedule ?? schedule, - recurrence: edit.recurrence, - scope: "all", - recurrenceId: null, - } as unknown as SyncCommandInput, - expectedVersion: "etag-1" as never, - }) - ).record; - - const masterOccurrences = (eventId: EventId) => - mongo.db - .collection(SYNC_COLLECTIONS.eventOccurrences) - .find({ eventId }) - .sort({ startAt: 1 }) - .toArray(); - - // Seed a provider-linked series exception. Real provider exceptions come from - // import (a later slice) and each carries its OWN provider event id, so this - // seeds a distinct providerEventId rather than reusing the master's — the - // provider-identity unique index forbids sharing it. - const putException = async ( - master: EventRecord, - opts: { - providerEventId: string; - recurrenceId: string; - cancelled: boolean; - title: string; - }, - ): Promise => { - const id = objectId() as EventId; - await events.put({ - _id: id, - tenantId: master.tenantId, - principalId: master.principalId, - origin: "compass", - calendarId: master.calendarId, - clientEventId: null, - connectionId: master.connectionId, - providerEventId: opts.providerEventId as never, - providerVersion: "etag-1" as never, - providerUpdatedAt: null, - deliveryState: "confirmed", - providerMetadata: null, - content: content(opts.title), - schedule, - recurrence: { - kind: "exception", - seriesId: master._id, - recurrenceId: opts.recurrenceId as never, - cancelled: opts.cancelled, - }, - lifecycleState: "active", - generation: 0, - createdAt: now(), - updatedAt: now(), - confirmedAt: now(), - } as never); - const stored = await events.findById( - master.tenantId, - master.principalId, - id, - ); - if (!stored) throw new Error("seed failed to read back the exception"); - return stored; - }; - - const deps = (writer: ProviderEventWriter) => - providerMutationDeps({ commands, events, occurrences, resources }, writer); - - it("patches the whole series and reprojects with the edited content", async () => { - const { tenantId, principalId, calendar, master } = await seedMaster(); - const command = await editAllCommand(master, { - title: "New", - recurrence: { kind: "preserve" }, - }); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerSeries("Old", "etag-1", weekly4); - writer.patchResult = { - providerEventId: "g-evt-1" as ProviderEventId, - providerVersion: "etag-2", - }; - - const result = await executeProviderSeriesUpdate( - deps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - // Preserve re-writes the master's own rules; the whole series is patched. - expect(writer.patchCalls).toHaveLength(1); - expect(writer.patchCalls[0]!.recurrence).toEqual({ - kind: "series", - rules: [...weekly4], - }); - const stored = await events.findById(tenantId, principalId, master._id); - expect(stored?.content.title).toBe("New"); - expect(stored?.providerVersion).toBe("etag-2" as ProviderEventVersion); - expect(stored?.recurrence).toEqual({ - kind: "seriesMaster", - rules: [...weekly4], - }); - // All four occurrences carry the edited title. - const occ = await masterOccurrences(master._id); - expect(occ).toHaveLength(4); - expect(occ.every((o) => o["title"] === "New")).toBe(true); - }); - - it("patches on a rules-only edit instead of treating it as a replay", async () => { - const { tenantId, principalId, calendar, master } = await seedMaster(); - // Same title and schedule, only the recurrence rule shrinks 4 -> 2. Without - // comparing recurrence this would look identical to the provider's current - // state and be confirmed WITHOUT ever writing the new rule. - const command = await editAllCommand(master, { - title: "Old", - recurrence: { kind: "series", rules: weekly2 }, - }); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerSeries("Old", "etag-1", weekly4); - writer.patchResult = { - providerEventId: "g-evt-1" as ProviderEventId, - providerVersion: "etag-2", - }; - - const result = await executeProviderSeriesUpdate( - deps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls).toHaveLength(1); - expect(writer.patchCalls[0]!.recurrence).toEqual({ - kind: "series", - rules: [...weekly2], - }); - const stored = await events.findById(tenantId, principalId, master._id); - expect(stored?.recurrence).toEqual({ - kind: "seriesMaster", - rules: [...weekly2], - }); - // The horizon now holds only the two remaining occurrences. - expect(await masterOccurrences(master._id)).toHaveLength(2); - }); - - it("confirms without re-patching when the whole edit already landed", async () => { - const { calendar, master } = await seedMaster(); - const command = await editAllCommand(master, { - title: "New", - recurrence: { kind: "series", rules: weekly2 }, - }); - const writer = new FakeProviderEventWriter(); - // Provider already holds the edited content AND the new rules at a fresh - // version — a prior attempt landed before the crash. - writer.fetched = providerSeries("New", "etag-2", weekly2); - - const result = await executeProviderSeriesUpdate( - deps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls).toHaveLength(0); - }); - - it("treats a reformatted-but-equivalent rule echo as a replay", async () => { - const { calendar, master } = await seedMaster(); - const command = await editAllCommand(master, { - title: "New", - recurrence: { - kind: "series", - rules: ["RRULE:FREQ=WEEKLY;COUNT=4;INTERVAL=1"], - }, - }); - const writer = new FakeProviderEventWriter(); - // The provider echoes the same rule reordered and lowercased at a new - // version — our edit landed on a prior attempt. A byte-for-byte compare - // would miss it and re-patch with a now-stale version, failing a write that - // already succeeded. - writer.fetched = providerSeries("New", "etag-2", [ - "rrule:interval=1;count=4;freq=weekly", - ]); - - const result = await executeProviderSeriesUpdate( - deps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls).toHaveLength(0); - }); - - it("patches against the fresh version when only the master's version key drifted", async () => { - const { calendar, master } = await seedMaster(); - const command = await editAllCommand(master, { - title: "New", - recurrence: { kind: "preserve" }, - }); - const writer = new FakeProviderEventWriter(); - // Same content, schedule, and rules as stored; only the version rotated. - writer.fetched = providerSeries("Old", "etag-1-rotated", weekly4); - - const result = await executeProviderSeriesUpdate( - deps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls).toHaveLength(1); - expect(writer.patchCalls[0]!.expectedVersion).toBe("etag-1-rotated"); - }); - - it("fails with a conflict on a genuine concurrent external edit", async () => { - const { calendar, master } = await seedMaster(); - const command = await editAllCommand(master, { - title: "New", - recurrence: { kind: "preserve" }, - }); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerSeries("Someone else", "etag-9", weekly4); - writer.patchError = new ProviderWriteError("versionConflict", "stale"); - - const result = await executeProviderSeriesUpdate( - deps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("failed"); - expect( - result.outcome.state === "failed" && result.outcome.failureReason, - ).toBe("versionConflict"); - // Content changed too, so the stale version stays on the patch. - expect(writer.patchCalls[0]!.expectedVersion).toBe("etag-1"); - }); - - it("discards override exceptions but keeps cancelled tombstones", async () => { - const { tenantId, principalId, calendar, master } = await seedMaster(); - // An override on the 2nd instant and a cancellation on the 3rd. - const override = await putException(master, { - providerEventId: "g-inst-override" as ProviderEventId, - recurrenceId: "2026-07-21T09:00:00-06:00", - cancelled: false, - title: "Moved", - }); - await putException(master, { - providerEventId: "g-inst-cancelled" as ProviderEventId, - recurrenceId: "2026-07-28T09:00:00-06:00", - cancelled: true, - title: "Old", - }); - const command = await editAllCommand(master, { - title: "New", - recurrence: { kind: "preserve" }, - }); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerSeries("Old", "etag-1", weekly4); - - const result = await executeProviderSeriesUpdate( - deps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - // Align the content override at the provider (do not cancel it); leave the - // kept tombstone untouched. - expect(writer.deleteCalls).toHaveLength(0); - const overridePatch = writer.patchCalls.find( - (call) => call.providerEventId === "g-inst-override", - ); - expect(overridePatch).toMatchObject({ - providerEventId: "g-inst-override" as ProviderEventId, - expectedVersion: null, - invitation: "all", - calendarId: calendar.providerCalendarId, - recurrence: { kind: "instance" }, - content: expect.objectContaining({ title: "New" }), - schedule: { - kind: "timed", - start: "2026-07-21T09:00:00-06:00" as DateTime, - end: "2026-07-21T10:00:00-06:00" as DateTime, - timeZone: "America/Denver" as TimeZone, - }, - }); - // The override is gone; the cancelled tombstone survives the edit. - expect( - await events.findById(tenantId, principalId, override._id), - ).toBeNull(); - const remaining = await events.findSeriesExceptions( - tenantId, - principalId, - master._id, - ); - expect(remaining).toHaveLength(1); - expect( - remaining[0]?.recurrence.kind === "exception" && - remaining[0]?.recurrence.cancelled, - ).toBe(true); - // The reprojected master excludes the cancelled instant (2026-07-28 15:00Z) - // but re-covers the formerly-overridden instant, so three master rows remain. - const occ = await masterOccurrences(master._id); - const starts = occ.map((o) => (o["startAt"] as Date).toISOString()); - expect(starts).toEqual([ - "2026-07-14T15:00:00.000Z", - "2026-07-21T15:00:00.000Z", - "2026-08-04T15:00:00.000Z", - ]); - }); - - it("leaves the override local when provider override align is transient", async () => { - const { tenantId, principalId, calendar, master } = await seedMaster(); - const override = await putException(master, { - providerEventId: "g-inst-override" as ProviderEventId, - recurrenceId: "2026-07-21T09:00:00-06:00", - cancelled: false, - title: "Moved", - }); - const command = await editAllCommand(master, { - title: "New", - recurrence: { kind: "preserve" }, - }); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerSeries("Old", "etag-1", weekly4); - writer.instancePatchError = new ProviderWriteError( - "transient", - "rate limited", - ); - - const result = await executeProviderSeriesUpdate( - deps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("pending"); - expect(writer.deleteCalls).toHaveLength(0); - expect( - writer.patchCalls.some( - (call) => call.providerEventId === "g-inst-override", - ), - ).toBe(true); - expect( - await events.findById(tenantId, principalId, override._id), - ).not.toBeNull(); - }); - - it("aligns discarded overrides to a series time change", async () => { - const { calendar, master } = await seedMaster(); - await putException(master, { - providerEventId: "g-inst-override" as ProviderEventId, - recurrenceId: "2026-07-21T09:00:00-06:00", - cancelled: false, - title: "Moved", - }); - const movedSchedule = { - kind: "timed" as const, - start: "2026-07-14T10:00:00-06:00" as DateTime, - end: "2026-07-14T11:00:00-06:00" as DateTime, - timeZone: "America/Denver" as TimeZone, - }; - const command = await editAllCommand(master, { - title: "New", - recurrence: { kind: "preserve" }, - schedule: movedSchedule, - }); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerSeries("Old", "etag-1", weekly4); - - const result = await executeProviderSeriesUpdate( - deps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - const overridePatch = writer.patchCalls.find( - (call) => call.providerEventId === "g-inst-override", - ); - expect(overridePatch?.schedule).toEqual({ - kind: "timed", - start: "2026-07-21T10:00:00-06:00" as DateTime, - end: "2026-07-21T11:00:00-06:00" as DateTime, - timeZone: "America/Denver" as TimeZone, - }); - }); - - it("continues edit-all when the override is already gone at the provider", async () => { - const { tenantId, principalId, calendar, master } = await seedMaster(); - const override = await putException(master, { - providerEventId: "g-inst-override" as ProviderEventId, - recurrenceId: "2026-07-21T09:00:00-06:00", - cancelled: false, - title: "Moved", - }); - const command = await editAllCommand(master, { - title: "New", - recurrence: { kind: "preserve" }, - }); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerSeries("Old", "etag-1", weekly4); - writer.instancePatchError = new ProviderWriteError( - "permanentProviderError", - "Google rejected the write", - ); - - const result = await executeProviderSeriesUpdate( - deps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect( - writer.fetchCalls.some( - (call) => call.providerEventId === "g-inst-override", - ), - ).toBe(true); - expect( - await events.findById(tenantId, principalId, override._id), - ).toBeNull(); - const starts = (await masterOccurrences(master._id)).map((o) => - (o["startAt"] as Date).toISOString(), - ); - expect(starts).toContain("2026-07-21T15:00:00.000Z"); - }); - - it("converts a series to a single event, dropping every exception", async () => { - const { tenantId, principalId, calendar, master } = await seedMaster(); - await putException(master, { - providerEventId: "g-inst-cancelled" as ProviderEventId, - recurrenceId: "2026-07-28T09:00:00-06:00", - cancelled: true, - title: "Old", - }); - const command = await editAllCommand(master, { - title: "Just once", - recurrence: { kind: "single" }, - }); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerSeries("Old", "etag-1", weekly4); - - const result = await executeProviderSeriesUpdate( - deps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - // The provider write removes recurrence. - expect(writer.patchCalls[0]!.recurrence).toEqual({ kind: "single" }); - // Convert-to-single also cancels every discarded exception at the provider - // (including former cancellations) so a later pull cannot resurrect them. - expect(writer.deleteCalls).toEqual([ - expect.objectContaining({ - providerEventId: "g-inst-cancelled" as ProviderEventId, - }), - ]); - const stored = await events.findById(tenantId, principalId, master._id); - expect(stored?.recurrence).toEqual({ kind: "single" }); - // No exceptions survive a conversion to a single event, and the master - // projects exactly one occurrence. - expect( - await events.findSeriesExceptions(tenantId, principalId, master._id), - ).toHaveLength(0); - expect(await masterOccurrences(master._id)).toHaveLength(1); - }); - - it("converges when executed twice (idempotent retry)", async () => { - const { tenantId, principalId, calendar, master } = await seedMaster(); - const command = await editAllCommand(master, { - title: "New", - recurrence: { kind: "series", rules: weekly2 }, - }); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerSeries("Old", "etag-1", weekly4); - writer.patchResult = { - providerEventId: "g-evt-1" as ProviderEventId, - providerVersion: "etag-2", - }; - - await executeProviderSeriesUpdate( - deps(writer), - command, - master, - calendar, - now, - ); - // The provider now reflects the landed edit, so a retry is a replay. - writer.fetched = providerSeries("New", "etag-2", weekly2); - const second = await executeProviderSeriesUpdate( - deps(writer), - command, - master, - calendar, - now, - ); - - expect(second.outcome.state).toBe("confirmed"); - // Only the first attempt wrote; the retry recognized the replay. - expect(writer.patchCalls).toHaveLength(1); - const owned = await mongo.db - .collection(SYNC_COLLECTIONS.events) - .find({ tenantId, principalId, calendarId: calendar._id }) - .toArray(); - expect(owned).toHaveLength(1); - expect(await masterOccurrences(master._id)).toHaveLength(2); - }); - - it("leaves the command pending on a transient patch failure", async () => { - const { calendar, master } = await seedMaster(); - const command = await editAllCommand(master, { - title: "New", - recurrence: { kind: "preserve" }, - }); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerSeries("Old", "etag-1", weekly4); - writer.patchError = new ProviderWriteError("transient", "blip"); - - const result = await executeProviderSeriesUpdate( - deps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("pending"); - }); -}); - -describe("provider-linked recurring scopes (this / thisAndFollowing)", () => { - // A weekly series of three occurrences starting 2026-07-14 09:00 Denver: - // 07-14, 07-21, 07-28 (all 15:00Z). Matches the cloud-path test fixtures - // exactly, so results are directly comparable. - const schedule = { - kind: "timed" as const, - start: "2026-07-14T09:00:00-06:00" as DateTime, - end: "2026-07-14T10:00:00-06:00" as DateTime, - timeZone: "America/Denver" as TimeZone, - }; - const weekly3 = ["RRULE:FREQ=WEEKLY;COUNT=3"]; - const SECOND_START = "2026-07-21T09:00:00-06:00"; - const SECOND_START_UTC = "2026-07-21T15:00:00.000Z"; - const content = (title: string) => ({ - title, - description: "", - location: null, - organizer: null, - attendees: [], - conference: null, - }); - const providerInstance = ( - providerEventId: string, - title: string, - version: string, - instanceSchedule = { - kind: "timed" as const, - start: SECOND_START as DateTime, - end: "2026-07-21T10:00:00-06:00" as DateTime, - timeZone: "America/Denver" as TimeZone, - }, - ): ProviderEvent => ({ - kind: "event", - providerEventId, - providerVersion: version, - providerUpdatedAt: null, - content: content(title), - schedule: instanceSchedule, - busy: true, - // Matches what the real normalizer reports for an event resolved off a - // series via fetchInstanceAt — NOT "single". A prior version of this - // fixture used "single", which papered over a bug where the replay - // short-circuit could never match a real instance read. - recurrence: { - kind: "instance", - seriesProviderId: "g-series-1", - recurrenceId: SECOND_START, - }, - }); - const providerSeries = ( - title: string, - version: string, - rules: readonly string[], - ): ProviderEvent => ({ - kind: "event", - providerEventId: "g-series-1" as ProviderEventId, - providerVersion: version, - providerUpdatedAt: null, - content: content(title), - schedule, - busy: true, - recurrence: { kind: "seriesMaster", rules: [...rules] }, - }); - - const seedMaster = async () => { - const ids = newCommandIds(); - const calendar = await seedCommandCalendar(calendars, ids); - const master = await seedLinkedEvent(events, { - ids, - calendarId: calendar._id, - providerEventId: "g-series-1" as ProviderEventId, - content: content("Old"), - schedule, - recurrence: { kind: "seriesMaster", rules: [...weekly3] }, - now: now(), - }); - await reprojectOccurrences(occurrences, master, now); - return { - tenantId: ids.tenantId, - principalId: ids.principalId, - calendar, - master, - }; - }; - - const occurrenceStartsFor = async (eventId: EventId): Promise => { - const docs = await mongo.db - .collection(SYNC_COLLECTIONS.eventOccurrences) - .find({ eventId }) - .sort({ startAt: 1 }) - .toArray(); - return docs.map((doc) => (doc["startAt"] as Date).toISOString()); - }; - - const otherSeriesMaster = (principalId: PrincipalId, masterId: EventId) => - mongo.db - .collection(SYNC_COLLECTIONS.events) - .find({ - principalId, - "recurrence.kind": "seriesMaster", - _id: { $ne: masterId }, - } as unknown as Filter) - .toArray(); - - const thisScopeCommand = async ( - master: EventRecord, - kind: "update" | "delete", - title = "Edited", - ) => - ( - await commands.submit({ - tenantId: master.tenantId, - principalId: master.principalId, - idempotencyKey: `idem-${objectId()}` as IdempotencyKey, - eventId: master._id, - input: - kind === "update" - ? ({ - kind: "update", - invitation: "none", - content: content(title), - schedule: { - kind: "timed", - start: SECOND_START, - end: "2026-07-21T10:00:00-06:00", - timeZone: "America/Denver", - }, - recurrence: { kind: "preserve" }, - scope: "this", - recurrenceId: SECOND_START, - } as unknown as SyncCommandInput) - : ({ - kind: "delete", - invitation: "none", - scope: "this", - recurrenceId: SECOND_START, - } as unknown as SyncCommandInput), - expectedVersion: null, - }) - ).record; - - const followingCommand = async ( - master: EventRecord, - kind: "update" | "delete", - splitAt = SECOND_START, - title = "Split", - ) => - ( - await commands.submit({ - tenantId: master.tenantId, - principalId: master.principalId, - idempotencyKey: `idem-${objectId()}` as IdempotencyKey, - eventId: master._id, - input: - kind === "update" - ? ({ - kind: "update", - invitation: "none", - content: content(title), - schedule: { - kind: "timed", - start: splitAt, - end: "2026-07-21T10:00:00-06:00", - timeZone: "America/Denver", - }, - recurrence: { - kind: "series", - rules: ["RRULE:FREQ=WEEKLY;COUNT=2"], - }, - scope: "thisAndFollowing", - recurrenceId: splitAt, - } as unknown as SyncCommandInput) - : ({ - kind: "delete", - invitation: "none", - scope: "thisAndFollowing", - recurrenceId: splitAt, - } as unknown as SyncCommandInput), - expectedVersion: null, - }) - ).record; - - const deps = (writer: FakeProviderEventWriter) => - providerMutationDeps({ commands, events, occurrences, resources }, writer); - const deleteDeps = (writer: FakeProviderEventWriter) => - providerDeleteDeps( - { commands, events, occurrences, resources, markers }, - writer, - ); - - describe("executeProviderOccurrenceUpdate", () => { - it("resolves the instance, patches IT (not the master), and stores its own provider identity", async () => { - const { tenantId, principalId, calendar, master } = await seedMaster(); - const command = await thisScopeCommand(master, "update", "Moved"); - const writer = new FakeProviderEventWriter(); - writer.fetchInstanceResult = providerInstance( - "g-inst-1", - "Old", - "etag-1", - ); - - const result = await executeProviderOccurrenceUpdate( - deps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.fetchInstanceCalls[0]).toMatchObject({ - seriesProviderEventId: "g-series-1", - originalStartAt: SECOND_START, - scheduleKind: "timed", - }); - // Patched the INSTANCE's own resolved id, never the master's. - expect(writer.patchCalls[0]?.providerEventId).toBe("g-inst-1"); - // "instance", not "single" — Google rejects a recurrence key at all on - // an event resolved off a series via fetchInstanceAt. - expect(writer.patchCalls[0]?.recurrence).toEqual({ kind: "instance" }); - - const exceptions = await events.findSeriesExceptions( - tenantId, - principalId, - master._id, - ); - expect(exceptions).toHaveLength(1); - // The exception carries the INSTANCE's own provider identity, not the - // master's — sharing the master's would collide the unique - // provider_event_identity index. - expect(exceptions[0]?.providerEventId).toBe( - "g-inst-1" as ProviderEventId, - ); - expect(exceptions[0]?.content.title).toBe("Moved"); - // The master no longer projects the overridden instant. - expect(await occurrenceStartsFor(master._id)).not.toContain( - SECOND_START_UTC, - ); - }); - - it("confirms without re-patching when the edit already landed (replay)", async () => { - const { calendar, master } = await seedMaster(); - const command = await thisScopeCommand(master, "update", "Moved"); - const writer = new FakeProviderEventWriter(); - // The instance already carries this command's intended content. - writer.fetchInstanceResult = providerInstance( - "g-inst-1", - "Moved", - "etag-2", - ); - - const result = await executeProviderOccurrenceUpdate( - deps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls).toHaveLength(0); - }); - - it("fails without writing when no instance exists at that instant", async () => { - const { calendar, master } = await seedMaster(); - const command = await thisScopeCommand(master, "update"); - const writer = new FakeProviderEventWriter(); - writer.fetchInstanceResult = null; - - const result = await executeProviderOccurrenceUpdate( - deps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("failed"); - expect( - result.outcome.state === "failed" && result.outcome.failureReason, - ).toBe("permanentProviderError"); - expect(writer.patchCalls).toHaveLength(0); - }); - - it("leaves the command pending on a transient patch failure", async () => { - const { calendar, master } = await seedMaster(); - const command = await thisScopeCommand(master, "update"); - const writer = new FakeProviderEventWriter(); - writer.fetchInstanceResult = providerInstance( - "g-inst-1", - "Old", - "etag-1", - ); - writer.patchError = new ProviderWriteError("transient", "blip"); - - const result = await executeProviderOccurrenceUpdate( - deps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("pending"); - }); - }); - - describe("executeProviderOccurrenceDelete", () => { - it("deletes the resolved instance at the provider and tombstones it locally", async () => { - const { tenantId, principalId, calendar, master } = await seedMaster(); - const command = await thisScopeCommand(master, "delete"); - const writer = new FakeProviderEventWriter(); - writer.fetchInstanceResult = providerInstance( - "g-inst-1", - "Old", - "etag-1", - ); - - const result = await executeProviderOccurrenceDelete( - deleteDeps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.deleteCalls[0]?.providerEventId).toBe("g-inst-1"); - const exceptions = await events.findSeriesExceptions( - tenantId, - principalId, - master._id, - ); - expect(exceptions).toHaveLength(1); - expect( - exceptions[0]?.recurrence.kind === "exception" && - exceptions[0]?.recurrence.cancelled, - ).toBe(true); - // The master no longer projects the cancelled instant; the whole - // series and every OTHER instance are untouched. - expect(await occurrenceStartsFor(master._id)).toEqual([ - "2026-07-14T15:00:00.000Z", - "2026-07-28T15:00:00.000Z", - ]); - }); - - it("converges without a second provider call when the instance is already gone", async () => { - const { tenantId, principalId, calendar, master } = await seedMaster(); - const command = await thisScopeCommand(master, "delete"); - const writer = new FakeProviderEventWriter(); - writer.fetchInstanceResult = null; - - const result = await executeProviderOccurrenceDelete( - deleteDeps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.deleteCalls).toHaveLength(0); - const exceptions = await events.findSeriesExceptions( - tenantId, - principalId, - master._id, - ); - expect(exceptions).toHaveLength(1); - // Regression lock: the tombstone must NOT mirror the master's own - // providerEventId (master.providerEventId is non-null here, since this - // is a provider-linked series) — doing so would collide the - // provider_event_identity unique index the master document already - // occupies. There is no live provider counterpart, so it's null. - expect(exceptions[0]?.providerEventId).toBeNull(); - }); - - it("leaves the command pending on a transient delete failure", async () => { - const { calendar, master } = await seedMaster(); - const command = await thisScopeCommand(master, "delete"); - const writer = new FakeProviderEventWriter(); - writer.fetchInstanceResult = providerInstance( - "g-inst-1", - "Old", - "etag-1", - ); - writer.deleteError = new ProviderWriteError("transient", "blip"); - - const result = await executeProviderOccurrenceDelete( - deleteDeps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("pending"); - }); - - it("fails without tombstoning when the provider declines the delete (unsupportedCapability)", async () => { - // Google 400s a well-formed instance delete for special events (e.g. a - // contact-linked birthday occurrence). The event still exists at the - // provider, so hiding it locally would desync until the next pull - // resurrected it — the command fails honestly instead. - const { tenantId, principalId, calendar, master } = await seedMaster(); - const command = await thisScopeCommand(master, "delete"); - const writer = new FakeProviderEventWriter(); - writer.fetchInstanceResult = providerInstance( - "g-inst-1", - "Old", - "etag-1", - ); - writer.deleteError = new ProviderWriteError( - "unsupportedCapability", - "declined", - ); - - const result = await executeProviderOccurrenceDelete( - deleteDeps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("failed"); - expect( - result.outcome.state === "failed" && result.outcome.failureReason, - ).toBe("unsupportedCapability"); - // No local trace of the refused delete: no cancelled exception, and the - // occurrence still projects. - const exceptions = await events.findSeriesExceptions( - tenantId, - principalId, - master._id, - ); - expect(exceptions).toHaveLength(0); - expect(await occurrenceStartsFor(master._id)).toContain(SECOND_START_UTC); - }); - - it("still deletes when the resolved instance is identity-only (unreadable content)", async () => { - const { tenantId, principalId, calendar, master } = await seedMaster(); - const command = await thisScopeCommand(master, "delete"); - const writer = new FakeProviderEventWriter(); - writer.fetchInstanceResult = { - ...providerInstance("g-inst-unreadable", "", "etag-1"), - content: content(""), - }; - - const result = await executeProviderOccurrenceDelete( - deleteDeps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.deleteCalls[0]?.providerEventId).toBe("g-inst-unreadable"); - const exceptions = await events.findSeriesExceptions( - tenantId, - principalId, - master._id, - ); - expect(exceptions[0]?.providerEventId).toBe( - "g-inst-unreadable" as ProviderEventId, - ); - }); - - it("does not delete the series master when lookup returns the master's id", async () => { - const { tenantId, principalId, calendar, master } = await seedMaster(); - const command = await thisScopeCommand(master, "delete"); - const writer = new FakeProviderEventWriter(); - writer.fetchInstanceResult = providerSeries("Old", "etag-1", weekly3); - - const result = await executeProviderOccurrenceDelete( - deleteDeps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.deleteCalls).toHaveLength(0); - const exceptions = await events.findSeriesExceptions( - tenantId, - principalId, - master._id, - ); - expect(exceptions).toHaveLength(1); - expect(exceptions[0]?.providerEventId).toBeNull(); - }); - }); - - describe("executeProviderSeriesFollowingDelete", () => { - it("truncates the provider master and drops following occurrences", async () => { - const { tenantId, principalId, calendar, master } = await seedMaster(); - const command = await followingCommand(master, "delete"); - const writer = new FakeProviderEventWriter(); - writer.fetchEventResult = providerSeries("Old", "etag-1", weekly3); - - const result = await executeProviderSeriesFollowingDelete( - deleteDeps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls[0]?.recurrence.kind).toBe("series"); - // Content/schedule are unchanged — only the rules were patched. - expect(writer.patchCalls[0]?.content.title).toBe("Old"); - const stored = await events.findById(tenantId, principalId, master._id); - expect(stored?.recurrence.kind).toBe("seriesMaster"); - expect(await occurrenceStartsFor(master._id)).toEqual([ - "2026-07-14T15:00:00.000Z", - ]); - }); - - it("confirms without re-patching when the truncation already landed", async () => { - const { calendar, master } = await seedMaster(); - const command = await followingCommand(master, "delete"); - const writer = new FakeProviderEventWriter(); - // Provider already reflects the truncated rules — the exact UNTIL-based - // form truncateRulesBefore itself produces, not just an equivalent - // COUNT-based rule (matchesIntendedEdit compares rule strings, not - // recurrence semantics, so only this form is recognized as a replay). - writer.fetchEventResult = providerSeries( - "Old", - "etag-2", - truncateRulesBefore(weekly3, new Date(SECOND_START)), - ); - - const result = await executeProviderSeriesFollowingDelete( - deleteDeps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls).toHaveLength(0); - }); - - it("collapses to the whole-series provider delete at the first occurrence", async () => { - const { tenantId, principalId, calendar, master } = await seedMaster(); - const command = await followingCommand( - master, - "delete", - "2026-07-14T09:00:00-06:00", - ); - const writer = new FakeProviderEventWriter(); - - const result = await executeProviderSeriesFollowingDelete( - deleteDeps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - // executeProviderDelete's path was taken: the whole event is gone. - expect(writer.deleteCalls).toHaveLength(1); - expect( - await events.findById(tenantId, principalId, master._id), - ).toBeNull(); - }); - - it("leaves the command pending on a transient patch failure", async () => { - const { calendar, master } = await seedMaster(); - const command = await followingCommand(master, "delete"); - const writer = new FakeProviderEventWriter(); - writer.fetchEventResult = providerSeries("Old", "etag-1", weekly3); - writer.patchError = new ProviderWriteError("transient", "blip"); - - const result = await executeProviderSeriesFollowingDelete( - deleteDeps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("pending"); - }); - }); - - describe("executeProviderSeriesFollowingUpdate", () => { - it("truncates the original and creates a deterministic remainder at the provider", async () => { - const { principalId, calendar, master } = await seedMaster(); - const command = await followingCommand(master, "update"); - const writer = new FakeProviderEventWriter(); - writer.fetchEventResult = providerSeries("Old", "etag-1", weekly3); - writer.createResult = { - providerEventId: "g-remainder-1" as ProviderEventId, - providerVersion: "etag-1", - }; - - const result = await executeProviderSeriesFollowingUpdate( - deps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - // Original truncated to just the pre-split occurrence. - expect(await occurrenceStartsFor(master._id)).toEqual([ - "2026-07-14T15:00:00.000Z", - ]); - // Remainder created at the provider with the deterministic id. - expect(writer.createCalls).toHaveLength(1); - const remainders = await otherSeriesMaster(principalId, master._id); - expect(remainders).toHaveLength(1); - expect(writer.createCalls[0]?.providerEventId).toBe( - String(remainders[0]?.["_id"]), - ); - expect(remainders[0]?.["content"]).toMatchObject({ title: "Split" }); - const remainderId = String(remainders[0]?.["_id"]) as EventId; - expect(await occurrenceStartsFor(remainderId)).toEqual([ - SECOND_START_UTC, - "2026-07-28T15:00:00.000Z", - ]); - }); - - it("upserts a single remainder across two splits at the same point (idempotent retry)", async () => { - // Two distinct commands (fresh idempotency keys), same split point — - // mirrors the cloud path's own convergence test. The deterministic - // remainder id (remainderMasterId) means deps.events.put upserts the - // SAME Mongo document both times, regardless of whether each call's - // own provider-replay check fires — that Mongo-level convergence is - // what this test locks in. - const { principalId, calendar, master } = await seedMaster(); - const first = await followingCommand( - master, - "update", - SECOND_START, - "First", - ); - const writerA = new FakeProviderEventWriter(); - writerA.fetchEventResult = providerSeries("Old", "etag-1", weekly3); - await executeProviderSeriesFollowingUpdate( - deps(writerA), - first, - master, - calendar, - now, - ); - - const second = await followingCommand( - master, - "update", - SECOND_START, - "Second", - ); - const writerB = new FakeProviderEventWriter(); - writerB.fetchEventResult = providerSeries("Old", "etag-1", weekly3); - - await executeProviderSeriesFollowingUpdate( - deps(writerB), - second, - master, - calendar, - now, - ); - - expect(await otherSeriesMaster(principalId, master._id)).toHaveLength(1); - }); - - it("collapses to the provider edit-all at the first occurrence", async () => { - const { tenantId, principalId, calendar, master } = await seedMaster(); - const command = await followingCommand( - master, - "update", - "2026-07-14T09:00:00-06:00", - "Whole", - ); - const writer = new FakeProviderEventWriter(); - writer.fetchEventResult = providerSeries("Old", "etag-1", weekly3); - - const result = await executeProviderSeriesFollowingUpdate( - deps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.createCalls).toHaveLength(0); - const stored = await events.findById(tenantId, principalId, master._id); - expect(stored?.content.title).toBe("Whole"); - expect(await otherSeriesMaster(principalId, master._id)).toHaveLength(0); - }); - - it("leaves the command pending on a transient create failure for the remainder", async () => { - const { calendar, master } = await seedMaster(); - const command = await followingCommand(master, "update"); - const writer = new FakeProviderEventWriter(); - writer.fetchEventResult = providerSeries("Old", "etag-1", weekly3); - writer.createError = new ProviderWriteError("transient", "blip"); - - const result = await executeProviderSeriesFollowingUpdate( - deps(writer), - command, - master, - calendar, - now, - ); - - expect(result.outcome.state).toBe("pending"); - }); - }); -}); - -// WP-02: attendeesEdit "replace" — merge-by-email against freshly fetched -// provider state, organizer guard, replay by email set, and byte-identical -// "preserve"/legacy behavior. -describe("attendeesEdit replace", () => { - const OWNER = "owner@example.com"; - - const schedule = { - kind: "timed" as const, - start: "2026-07-14T09:00:00-06:00" as DateTime, - end: "2026-07-14T10:00:00-06:00" as DateTime, - timeZone: "America/Denver" as TimeZone, - }; - - const attendee = ( - email: string, - responseStatus: Attendee["responseStatus"] = "needsAction", - displayName: string | null = null, - ): Attendee => ({ email, displayName, responseStatus }); - - const contentWith = ( - title: string, - opts: { - organizer?: { email: string; displayName: string | null } | null; - attendees?: Attendee[]; - } = {}, - ) => ({ - title, - description: "", - location: null, - organizer: opts.organizer ?? null, - attendees: opts.attendees ?? [], - conference: null, - }); - - const connectionsWith = (email: string | null): ProviderConnectionLookup => ({ - findById: async () => ({ account: { email }, provider: "google" }), - }); - const missingConnection: ProviderConnectionLookup = { - findById: async () => null, - }; - - const deps = ( - writer: ProviderEventWriter, - connections: ProviderConnectionLookup, - ) => ({ - commands, - events, - occurrences, - resources, - connections, - writer, - custody: tokenSource(), - }); - - const seedLinked = async (opts: { - organizer?: { email: string; displayName: string | null } | null; - storedAttendees?: Attendee[]; - recurrence?: { kind: "seriesMaster"; rules: string[] }; - }) => { - const ids = newCommandIds(); - const calendar = await seedCommandCalendar(calendars, ids); - const event = await seedLinkedEvent(events, { - ids, - calendarId: calendar._id, - content: contentWith("Old", { - organizer: opts.organizer, - attendees: opts.storedAttendees, - }), - schedule, - recurrence: opts.recurrence ?? { kind: "single" }, - now: now(), - }); - return { - tenantId: ids.tenantId, - principalId: ids.principalId, - calendar, - event, - }; - }; - - const replaceCommand = async ( - event: EventRecord, - opts: { - title?: string; - attendees: Attendee[]; - attendeesEdit?: "replace" | "preserve"; - scope?: string; - recurrenceId?: string | null; - recurrence?: unknown; - }, - ) => - ( - await commands.submit({ - tenantId: event.tenantId, - principalId: event.principalId, - idempotencyKey: `idem-${objectId()}` as IdempotencyKey, - eventId: event._id, - input: { - kind: "update", - invitation: "all", - attendeesEdit: opts.attendeesEdit ?? "replace", - content: contentWith(opts.title ?? "Old", { - attendees: opts.attendees, - }), - schedule, - recurrence: opts.recurrence ?? { kind: "preserve" }, - scope: opts.scope ?? "all", - recurrenceId: opts.recurrenceId ?? null, - } as unknown as SyncCommandInput, - expectedVersion: "etag-1" as never, - }) - ).record; - - const providerSingle = ( - title: string, - version: string, - attendees: Attendee[], - ): ProviderEvent => ({ - kind: "event", - providerEventId: "g-evt-1" as ProviderEventId, - providerVersion: version, - providerUpdatedAt: null, - content: contentWith(title, { - organizer: { email: OWNER, displayName: null }, - attendees, - }), - schedule, - busy: true, - recurrence: { kind: "single" }, - }); - - it("merges the intent against freshly fetched provider state and patches the full set", async () => { - // Acceptance "Normal": add one attendee to an event with three existing. - // The stored record is STALE (everyone needsAction); the provider copy has - // newer RSVPs that must survive the replace. - const { tenantId, principalId, calendar, event } = await seedLinked({ - organizer: { email: OWNER, displayName: null }, - storedAttendees: [ - attendee("a@example.com"), - attendee("b@example.com"), - attendee("c@example.com"), - ], - }); - const command = await replaceCommand(event, { - attendees: [ - attendee("a@example.com"), - attendee("b@example.com"), - attendee("c@example.com"), - attendee("d@example.com", "needsAction", "Dee"), - ], - }); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerSingle("Old", "etag-1", [ - attendee("a@example.com", "accepted"), - attendee("b@example.com", "needsAction"), - attendee("c@example.com", "declined", "Cee"), - ]); - - const result = await executeProviderUpdate( - // Case-insensitive: the connection reports the account email cased - // differently than the stored organizer. - deps(writer, connectionsWith("Owner@Example.COM")), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - const expectedMerged = [ - attendee("a@example.com", "accepted"), - attendee("b@example.com", "needsAction"), - attendee("c@example.com", "declined", "Cee"), - attendee("d@example.com", "needsAction", "Dee"), - ]; - expect(writer.patchCalls).toHaveLength(1); - expect(writer.patchCalls[0]!.attendees).toEqual(expectedMerged); - expect(writer.patchCalls[0]!.invitation).toBe("all"); - // The merged membership lands on the sync record at confirm, so reads - // reflect it before the next Google round-trip. - const stored = await events.findById(tenantId, principalId, event._id); - expect(stored?.content.attendees).toEqual(expectedMerged); - }); - - it("replaces with an empty set to remove every guest", async () => { - const { tenantId, principalId, calendar, event } = await seedLinked({ - organizer: { email: OWNER, displayName: null }, - storedAttendees: [attendee("a@example.com", "accepted")], - }); - const command = await replaceCommand(event, { attendees: [] }); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerSingle("Old", "etag-1", [ - attendee("a@example.com", "accepted"), - ]); - - const result = await executeProviderUpdate( - deps(writer, connectionsWith(OWNER)), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls[0]!.attendees).toEqual([]); - const stored = await events.findById(tenantId, principalId, event._id); - expect(stored?.content.attendees).toEqual([]); - }); - - it("fails a non-organizer replace typed, before any provider call", async () => { - const { calendar, event } = await seedLinked({ - organizer: { email: "someone-else@example.com", displayName: null }, - storedAttendees: [attendee("a@example.com", "accepted")], - }); - const command = await replaceCommand(event, { - attendees: [attendee("a@example.com"), attendee("b@example.com")], - }); - const writer = new FakeProviderEventWriter(); - - const result = await executeProviderUpdate( - deps(writer, connectionsWith(OWNER)), - command, - event, - calendar, - now, - ); - - expect(result.outcome).toEqual({ - state: "failed", - failureReason: "unsupportedCapability", - }); - // No provider call of any kind — not even the replay-detection fetch. - expect(writer.fetchCalls).toHaveLength(0); - expect(writer.patchCalls).toHaveLength(0); - // The failure surface the command route logs from (and the SSE notices - // derive from) carries no attendee JSON or event content. - expect(findSafetyCanaryHit(result.outcome)).toBeNull(); - expect( - findSafetyCanaryHit( - `Command ${result._id} (${result.input.kind} ${result.eventId}) failed: ${ - result.outcome.state === "failed" && result.outcome.failureReason - }`, - ), - ).toBeNull(); - }); - - it("fails closed when the connection cannot be resolved", async () => { - const { calendar, event } = await seedLinked({ - organizer: { email: OWNER, displayName: null }, - }); - const command = await replaceCommand(event, { - attendees: [attendee("a@example.com")], - }); - const writer = new FakeProviderEventWriter(); - - const result = await executeProviderUpdate( - deps(writer, missingConnection), - command, - event, - calendar, - now, - ); - - expect(result.outcome).toEqual({ - state: "failed", - failureReason: "unsupportedCapability", - }); - expect(writer.fetchCalls).toHaveLength(0); - }); - - it("allows a replace when no organizer is stored yet", async () => { - // A Compass-created event that has never had guests carries no organizer; - // the connection's own account organizes it. - const { calendar, event } = await seedLinked({ organizer: null }); - const command = await replaceCommand(event, { - attendees: [attendee("a@example.com")], - }); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerSingle("Old", "etag-1", []); - - const result = await executeProviderUpdate( - deps(writer, connectionsWith(OWNER)), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls[0]!.attendees).toEqual([ - attendee("a@example.com"), - ]); - }); - - it("confirms a landed attendee-only edit on replay — email sets, order- and status-insensitive", async () => { - const { calendar, event } = await seedLinked({ - organizer: { email: OWNER, displayName: null }, - storedAttendees: [attendee("a@example.com")], - }); - const command = await replaceCommand(event, { - attendees: [attendee("a@example.com"), attendee("b@example.com")], - }); - const writer = new FakeProviderEventWriter(); - // The prior attempt landed; since then the provider reordered the list - // and one guest RSVP'd. Same membership => replay, never a second write. - writer.fetched = providerSingle("Old", "etag-7", [ - attendee("b@example.com", "accepted"), - attendee("A@Example.com", "declined"), - ]); - - const result = await executeProviderUpdate( - deps(writer, connectionsWith(OWNER)), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls).toHaveLength(0); - expect( - result.outcome.state === "confirmed" && result.outcome.providerVersion, - ).toBe("etag-7" as ProviderEventVersion); - }); - - it("still patches when the landed membership differs from the intent", async () => { - const { calendar, event } = await seedLinked({ - organizer: { email: OWNER, displayName: null }, - }); - const command = await replaceCommand(event, { - attendees: [attendee("a@example.com"), attendee("b@example.com")], - }); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerSingle("Old", "etag-1", [ - attendee("a@example.com", "accepted"), - attendee("c@example.com", "accepted"), - ]); - - const result = await executeProviderUpdate( - deps(writer, connectionsWith(OWNER)), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls).toHaveLength(1); - expect(writer.patchCalls[0]!.attendees).toEqual([ - attendee("a@example.com", "accepted"), - attendee("b@example.com"), - ]); - }); - - it("keeps a preserve command byte-identical: no attendees on the patch, stored list untouched", async () => { - const storedAttendees = [attendee("kept@example.com", "accepted")]; - const { tenantId, principalId, calendar, event } = await seedLinked({ - organizer: { email: OWNER, displayName: null }, - storedAttendees, - }); - // The browser echoes full content on legacy updates — attendees included — - // but "preserve" must not turn that into a guest write. - const command = await replaceCommand(event, { - title: "Renamed", - attendees: [attendee("stray@example.com")], - attendeesEdit: "preserve", - }); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerSingle("Old", "etag-1", [ - attendee("kept@example.com", "accepted"), - attendee("provider-only@example.com", "tentative"), - ]); - - const result = await executeProviderUpdate( - deps(writer, connectionsWith(OWNER)), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls).toHaveLength(1); - expect(writer.patchCalls[0]).not.toHaveProperty("attendees"); - expect(writer.patchCalls[0]!.content.attendees).toEqual(storedAttendees); - // The stored record keeps its own attendee list (mergeUpdateContent), not - // the command's echoed one. - const stored = await events.findById(tenantId, principalId, event._id); - expect(stored?.content.attendees).toEqual(storedAttendees); - expect(stored?.content.title).toBe("Renamed"); - }); - - it("leaves a replace pending on a transient fetch failure, with no patch", async () => { - // Acceptance "Tool failure": the provider fetch fails transiently. - const { calendar, event } = await seedLinked({ - organizer: { email: OWNER, displayName: null }, - }); - const command = await replaceCommand(event, { - attendees: [attendee("a@example.com")], - }); - const writer = new FakeProviderEventWriter(); - writer.fetchError = new ProviderWriteError("transient", "blip"); - - const result = await executeProviderUpdate( - deps(writer, connectionsWith(OWNER)), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("pending"); - expect(writer.patchCalls).toHaveLength(0); - }); - - it("merges and patches the guest list on a series edit-all", async () => { - const weekly4 = ["RRULE:FREQ=WEEKLY;COUNT=4"]; - const { tenantId, principalId, calendar, event } = await seedLinked({ - organizer: { email: OWNER, displayName: null }, - storedAttendees: [attendee("a@example.com")], - recurrence: { kind: "seriesMaster", rules: weekly4 }, - }); - const command = await replaceCommand(event, { - attendees: [attendee("a@example.com"), attendee("b@example.com")], - }); - const writer = new FakeProviderEventWriter(); - writer.fetched = { - ...providerSingle("Old", "etag-1", [ - attendee("a@example.com", "accepted"), - ]), - recurrence: { kind: "seriesMaster", rules: weekly4 }, - }; - - const result = await executeProviderSeriesUpdate( - deps(writer, connectionsWith(OWNER)), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls).toHaveLength(1); - expect(writer.patchCalls[0]!.attendees).toEqual([ - attendee("a@example.com", "accepted"), - attendee("b@example.com"), - ]); - const stored = await events.findById(tenantId, principalId, event._id); - expect(stored?.content.attendees).toEqual([ - attendee("a@example.com", "accepted"), - attendee("b@example.com"), - ]); - }); - - it("fails a non-organizer series edit-all replace before any provider call", async () => { - const { calendar, event } = await seedLinked({ - organizer: { email: "someone-else@example.com", displayName: null }, - recurrence: { - kind: "seriesMaster", - rules: ["RRULE:FREQ=WEEKLY;COUNT=4"], - }, - }); - const command = await replaceCommand(event, { - attendees: [attendee("a@example.com")], - }); - const writer = new FakeProviderEventWriter(); - - const result = await executeProviderSeriesUpdate( - deps(writer, connectionsWith(OWNER)), - command, - event, - calendar, - now, - ); - - expect(result.outcome).toEqual({ - state: "failed", - failureReason: "unsupportedCapability", - }); - expect(writer.fetchCalls).toHaveLength(0); - expect(writer.patchCalls).toHaveLength(0); - }); - - it("refuses a replace on a scope-this occurrence edit (whole-event only in v1)", async () => { - const { calendar, event } = await seedLinked({ - organizer: { email: OWNER, displayName: null }, - recurrence: { - kind: "seriesMaster", - rules: ["RRULE:FREQ=WEEKLY;COUNT=4"], - }, - }); - const command = await replaceCommand(event, { - attendees: [attendee("a@example.com")], - scope: "this", - recurrenceId: "2026-07-21T15:00:00.000Z", - }); - const writer = new FakeProviderEventWriter(); - - const result = await executeProviderOccurrenceUpdate( - deps(writer, connectionsWith(OWNER)), - command, - event, - calendar, - now, - ); - - expect(result.outcome).toEqual({ - state: "failed", - failureReason: "unsupportedCapability", - }); - expect(writer.patchCalls).toHaveLength(0); - }); - - it("refuses a replace on a thisAndFollowing split", async () => { - const { tenantId, principalId, calendar, event } = await seedLinked({ - organizer: { email: OWNER, displayName: null }, - recurrence: { - kind: "seriesMaster", - rules: ["RRULE:FREQ=WEEKLY;COUNT=4"], - }, - }); - const command = await replaceCommand(event, { - attendees: [attendee("a@example.com")], - scope: "thisAndFollowing", - recurrenceId: "2026-07-21T15:00:00.000Z", - }); - const writer = new FakeProviderEventWriter(); - - const result = await executeProviderSeriesFollowingUpdate( - deps(writer, connectionsWith(OWNER)), - command, - event, - calendar, - now, - ); - - expect(result.outcome).toEqual({ - state: "failed", - failureReason: "unsupportedCapability", - }); - expect(writer.patchCalls).toHaveLength(0); - // Refused before the split touched anything: the master's rules are - // untruncated. - const stored = await events.findById(tenantId, principalId, event._id); - expect(stored?.recurrence).toEqual({ - kind: "seriesMaster", - rules: ["RRULE:FREQ=WEEKLY;COUNT=4"], - }); - }); - - it("emits every intended guest as needsAction on create and stores them", async () => { - const tenantId = objectId() as TenantId; - const principalId = objectId() as PrincipalId; - const connectionId = objectId() as ConnectionId; - const calendar = await seedCommandCalendar(calendars, { - tenantId, - principalId, - connectionId, - }); - const { record: command } = await commands.submit({ - tenantId, - principalId, - idempotencyKey: `idem-${objectId()}` as IdempotencyKey, - eventId: objectId() as EventId, - input: { - kind: "create", - calendarId: calendar._id, - invitation: "all", - attendeesEdit: "replace", - // The command may carry stray statuses; a create normalizes every - // guest to needsAction (nobody has answered a brand-new invitation). - content: contentWith("Kickoff", { - attendees: [ - attendee("a@example.com", "accepted", "Aye"), - attendee("b@example.com"), - ], - }), - schedule, - recurrence: { kind: "single" }, - } as unknown as SyncCommandInput, - expectedVersion: null, - }); - const writer = new FakeProviderEventWriter(); - - const result = await executeProviderCreate( - deps(writer, connectionsWith(OWNER)), - command, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - const expected = [ - attendee("a@example.com", "needsAction", "Aye"), - attendee("b@example.com"), - ]; - expect(writer.calls).toHaveLength(1); - expect(writer.calls[0]!.attendees).toEqual(expected); - expect(writer.calls[0]!.invitation).toBe("all"); - const stored = await events.findById( - tenantId, - principalId, - command.eventId, - ); - expect(stored?.content.attendees).toEqual(expected); - }); - - it("keeps a legacy create byte-identical: no attendees on the provider write", async () => { - const tenantId = objectId() as TenantId; - const principalId = objectId() as PrincipalId; - const connectionId = objectId() as ConnectionId; - const calendar = await seedCommandCalendar(calendars, { - tenantId, - principalId, - connectionId, - }); - const { record: command } = await commands.submit({ - tenantId, - principalId, - idempotencyKey: `idem-${objectId()}` as IdempotencyKey, - eventId: objectId() as EventId, - // No attendeesEdit: the schema defaults it to "preserve" (legacy). - input: { - kind: "create", - calendarId: calendar._id, - invitation: "none", - content: contentWith("Plain"), - schedule, - recurrence: { kind: "single" }, - } as unknown as SyncCommandInput, - expectedVersion: null, - }); - const writer = new FakeProviderEventWriter(); - - const result = await executeProviderCreate( - deps(writer, connectionsWith(OWNER)), - command, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(command.input.kind === "create" && command.input.attendeesEdit).toBe( - "preserve", - ); - expect(writer.calls[0]).not.toHaveProperty("attendees"); - }); -}); - -// WP-07: rsvp command execution — rewrite ONLY the self attendee entry -// (matched case-insensitively by the connection's account email) against -// freshly fetched provider state, patch the full merged list with -// sendUpdates "none", target the master for scope "all" and the resolved -// Google instance for scope "this", replay without a second write, and fail -// typed (unsupportedCapability) on every guard. -describe("executeProviderRsvp", () => { - const SELF = "self@example.com"; - - const schedule = { - kind: "timed" as const, - start: "2026-07-14T09:00:00-06:00" as DateTime, - end: "2026-07-14T10:00:00-06:00" as DateTime, - timeZone: "America/Denver" as TimeZone, - }; - const weekly3 = ["RRULE:FREQ=WEEKLY;COUNT=3"]; - const SECOND_START_UTC = "2026-07-21T15:00:00.000Z"; - - const attendee = ( - email: string, - responseStatus: Attendee["responseStatus"] = "needsAction", - displayName: string | null = null, - ): Attendee => ({ email, displayName, responseStatus }); - - const contentWith = ( - title: string, - opts: { - organizer?: { email: string; displayName: string | null } | null; - attendees?: Attendee[]; - color?: EventColorSlot; - } = {}, - ) => ({ - title, - description: "", - location: null, - organizer: opts.organizer ?? null, - attendees: opts.attendees ?? [], - conference: null, - ...(opts.color ? { color: opts.color } : {}), - }); - - const connectionsWith = (email: string | null): ProviderConnectionLookup => ({ - findById: async () => ({ account: { email }, provider: "google" }), - }); - const missingConnection: ProviderConnectionLookup = { - findById: async () => null, - }; - - const deps = ( - writer: ProviderEventWriter, - connections: ProviderConnectionLookup, - ) => ({ - commands, - events, - occurrences, - resources, - connections, - writer, - custody: tokenSource(), - }); - - const seedLinked = async ( - opts: { - organizer?: { email: string; displayName: string | null } | null; - storedAttendees?: Attendee[]; - recurrence?: { kind: "seriesMaster"; rules: string[] }; - } = {}, - ) => { - const ids = newCommandIds(); - const calendar = await seedCommandCalendar(calendars, ids); - const event = await seedLinkedEvent(events, { - ids, - calendarId: calendar._id, - content: contentWith("Invited", { - organizer: opts.organizer ?? { - email: "organizer@example.com", - displayName: null, - }, - attendees: opts.storedAttendees ?? [ - attendee("organizer@example.com", "accepted"), - attendee(SELF, "accepted"), - ], - }), - schedule, - recurrence: opts.recurrence ?? { kind: "single" }, - now: now(), - }); - return { - tenantId: ids.tenantId, - principalId: ids.principalId, - calendar, - event, - }; - }; - - const rsvpCommand = async ( - event: EventRecord, - opts: { - responseStatus?: "accepted" | "declined" | "tentative"; - scope?: string; - recurrenceId?: string | null; - } = {}, - ) => - ( - await commands.submit({ - tenantId: event.tenantId, - principalId: event.principalId, - idempotencyKey: `idem-${objectId()}` as IdempotencyKey, - eventId: event._id, - input: { - kind: "rsvp", - responseStatus: opts.responseStatus ?? "declined", - scope: opts.scope ?? "all", - recurrenceId: opts.recurrenceId ?? null, - } as unknown as SyncCommandInput, - expectedVersion: null, - }) - ).record; - - const providerSingle = ( - version: string, - attendees: Attendee[], - opts: { color?: EventColorSlot } = {}, - ): ProviderEvent => ({ - kind: "event", - providerEventId: "g-evt-1" as ProviderEventId, - providerVersion: version, - providerUpdatedAt: null, - content: contentWith("Invited", { - organizer: { email: "organizer@example.com", displayName: null }, - attendees, - color: opts.color, - }) as ProviderEvent["content"], - schedule, - busy: true, - recurrence: { kind: "single" }, - }); - - it("rewrites only the self entry (case-insensitive) and patches the full list with sendUpdates none", async () => { - // Acceptance "Normal": accepted → declined on a single event. The - // account email is cased differently than the provider's entry, and the - // provider list carries fresher sibling RSVPs than the stored copy. - const { tenantId, principalId, calendar, event } = await seedLinked(); - const command = await rsvpCommand(event, { responseStatus: "declined" }); - const writer = new FakeProviderEventWriter(); - const fetchedList = [ - attendee("organizer@example.com", "accepted", "Org"), - attendee("Self@Example.COM", "accepted"), - attendee("other@example.com", "tentative", "Oth"), - ]; - writer.fetched = providerSingle("etag-1", fetchedList, { color: "coral" }); - - const result = await executeProviderRsvp( - deps(writer, connectionsWith(SELF)), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls).toHaveLength(1); - const patch = writer.patchCalls[0]; - // The full merged list rides the attendee body emission: only the self - // entry's responseStatus changed; every other entry — and the self - // entry's own email casing and displayName — is byte-identical to the - // freshly fetched provider state. - expect(patch!.attendees).toEqual([ - attendee("organizer@example.com", "accepted", "Org"), - attendee("Self@Example.COM", "declined"), - attendee("other@example.com", "tentative", "Oth"), - ]); - expect(patch!.attendees?.[0]).toEqual(fetchedList[0] as Attendee); - expect(patch!.attendees?.[2]).toEqual(fetchedList[2] as Attendee); - // Never emails the guest list, and never conditions on a version: a - // concurrent sibling RSVP must not block this one. - expect(patch!.invitation).toBe("none"); - expect(patch!.expectedVersion).toBeNull(); - expect(patch!.providerEventId).toBe("g-evt-1"); - // The echoed body carries the fetched content minus color/colorHex, so - // the patch cannot touch Google's color or label state. - expect(patch!.content.title).toBe("Invited"); - expect(patch!.content).not.toHaveProperty("color"); - expect(patch!.content).not.toHaveProperty("colorHex"); - - // The answer lands on the stored record before the next Google - // round-trip. - const stored = await events.findById(tenantId, principalId, event._id); - expect(stored?.content.attendees).toEqual([ - attendee("organizer@example.com", "accepted", "Org"), - attendee("Self@Example.COM", "declined"), - attendee("other@example.com", "tentative", "Oth"), - ]); - expect(stored?.providerVersion).toBe("etag-2" as ProviderEventVersion); - expect( - result.outcome.state === "confirmed" && result.outcome.providerVersion, - ).toBe("etag-2" as ProviderEventVersion); - }); - - it("confirms a replay without a second write when the provider already holds the answer", async () => { - const { calendar, event } = await seedLinked(); - const command = await rsvpCommand(event, { responseStatus: "tentative" }); - const writer = new FakeProviderEventWriter(); - // The prior attempt landed (or the user answered from another client). - writer.fetched = providerSingle("etag-7", [ - attendee("organizer@example.com", "accepted"), - attendee(SELF, "tentative"), - ]); - - const result = await executeProviderRsvp( - deps(writer, connectionsWith(SELF)), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls).toHaveLength(0); - expect( - result.outcome.state === "confirmed" && result.outcome.providerVersion, - ).toBe("etag-7" as ProviderEventVersion); - }); - - it("allows the organizer to RSVP their own event", async () => { - // Finish line 4: no organizer guard here — Google lists the organizer as - // an attendee of their own event, and answering it is theirs to do. - const { calendar, event } = await seedLinked({ - organizer: { email: SELF, displayName: null }, - storedAttendees: [ - attendee(SELF, "accepted"), - attendee("guest@example.com", "needsAction"), - ], - }); - const command = await rsvpCommand(event, { responseStatus: "tentative" }); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerSingle("etag-1", [ - attendee(SELF, "accepted"), - attendee("guest@example.com", "needsAction"), - ]); - - const result = await executeProviderRsvp( - deps(writer, connectionsWith(SELF)), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls[0]!.attendees).toEqual([ - attendee(SELF, "tentative"), - attendee("guest@example.com", "needsAction"), - ]); - }); - - it("fails typed when the account is not in the stored guest list, with no provider call", async () => { - // Acceptance "Policy": self not an attendee → unsupportedCapability - // BEFORE any provider call, and no attendee JSON anywhere the route - // logs from. - const { calendar, event } = await seedLinked({ - storedAttendees: [ - attendee("organizer@example.com", "accepted"), - attendee("someone-else@example.com", "needsAction"), - ], - }); - const command = await rsvpCommand(event); - const writer = new FakeProviderEventWriter(); - - const result = await executeProviderRsvp( - deps(writer, connectionsWith(SELF)), - command, - event, - calendar, - now, - ); - - expect(result.outcome).toEqual({ - state: "failed", - failureReason: "unsupportedCapability", - }); - expect(writer.fetchCalls).toHaveLength(0); - expect(writer.patchCalls).toHaveLength(0); - expect(findSafetyCanaryHit(result.outcome)).toBeNull(); - expect( - findSafetyCanaryHit( - `Command ${result._id} (${result.input.kind} ${result.eventId}) failed: ${ - result.outcome.state === "failed" && result.outcome.failureReason - }`, - ), - ).toBeNull(); - }); - - it("fails closed when the connection cannot be resolved", async () => { - const { calendar, event } = await seedLinked(); - const command = await rsvpCommand(event); - const writer = new FakeProviderEventWriter(); - - const result = await executeProviderRsvp( - deps(writer, missingConnection), - command, - event, - calendar, - now, - ); - - expect(result.outcome).toEqual({ - state: "failed", - failureReason: "unsupportedCapability", - }); - expect(writer.fetchCalls).toHaveLength(0); - }); - - it("fails closed when the connection has no account email", async () => { - const { calendar, event } = await seedLinked(); - const command = await rsvpCommand(event); - const writer = new FakeProviderEventWriter(); - - const result = await executeProviderRsvp( - deps(writer, connectionsWith(null)), - command, - event, - calendar, - now, - ); - - expect(result.outcome).toEqual({ - state: "failed", - failureReason: "unsupportedCapability", - }); - expect(writer.fetchCalls).toHaveLength(0); - }); - - it("fails typed when the provider no longer lists the account, without writing", async () => { - // The stored list still has SELF, but the fetched state does not - // (uninvited provider-side since the last pull): same typed refusal, - // discovered after the fetch — never a write. - const { calendar, event } = await seedLinked(); - const command = await rsvpCommand(event); - const writer = new FakeProviderEventWriter(); - writer.fetched = providerSingle("etag-3", [ - attendee("organizer@example.com", "accepted"), - ]); - - const result = await executeProviderRsvp( - deps(writer, connectionsWith(SELF)), - command, - event, - calendar, - now, - ); - - expect(result.outcome).toEqual({ - state: "failed", - failureReason: "unsupportedCapability", - }); - expect(writer.fetchCalls).toHaveLength(1); - expect(writer.patchCalls).toHaveLength(0); - }); - - it("leaves the command pending on a transient fetch failure, with no patch", async () => { - // Acceptance "Tool failure": fetch 5xx → the command stays retryable. - const { calendar, event } = await seedLinked(); - const command = await rsvpCommand(event); - const writer = new FakeProviderEventWriter(); - writer.fetchError = new ProviderWriteError("transient", "blip"); - - const result = await executeProviderRsvp( - deps(writer, connectionsWith(SELF)), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("pending"); - expect(writer.patchCalls).toHaveLength(0); - }); - - it("fails permanently when nothing live exists to answer", async () => { - const { calendar, event } = await seedLinked(); - const command = await rsvpCommand(event); - const writer = new FakeProviderEventWriter(); - writer.fetched = null; - - const result = await executeProviderRsvp( - deps(writer, connectionsWith(SELF)), - command, - event, - calendar, - now, - ); - - expect(result.outcome).toEqual({ - state: "failed", - failureReason: "permanentProviderError", - }); - expect(writer.patchCalls).toHaveLength(0); - }); - - // --- instance-vs-master targeting ----------------------------------------- - - const providerInstance = (attendees: Attendee[]): ProviderEvent => ({ - kind: "event", - providerEventId: "g-inst-1" as ProviderEventId, - providerVersion: "etag-inst-1", - providerUpdatedAt: null, - content: contentWith("Invited", { - organizer: { email: "organizer@example.com", displayName: null }, - attendees, - }) as ProviderEvent["content"], - schedule: { - kind: "timed", - start: "2026-07-21T09:00:00-06:00" as DateTime, - end: "2026-07-21T10:00:00-06:00" as DateTime, - timeZone: "America/Denver" as TimeZone, - }, - busy: true, - recurrence: { - kind: "instance", - seriesProviderId: "g-evt-1", - recurrenceId: SECOND_START_UTC, - }, - }); - - it("patches the resolved Google instance on a scope-this rsvp, leaving the master untouched", async () => { - // Acceptance "Normal": declined on ONE occurrence leaves the master and - // sibling occurrences untouched. The instance id comes from the writer's - // own fetchInstanceAt resolution — never hand-built. - const { tenantId, principalId, calendar, event } = await seedLinked({ - recurrence: { kind: "seriesMaster", rules: weekly3 }, - }); - const command = await rsvpCommand(event, { - responseStatus: "declined", - scope: "this", - recurrenceId: SECOND_START_UTC, - }); - const writer = new FakeProviderEventWriter(); - writer.fetchInstanceResult = providerInstance([ - attendee("organizer@example.com", "accepted"), - attendee(SELF, "accepted"), - ]); - writer.patchResult = { - providerEventId: "g-inst-1" as ProviderEventId, - providerVersion: "etag-inst-2", - }; - - const result = await executeProviderRsvp( - deps(writer, connectionsWith(SELF)), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - // The occurrence was resolved off the series via fetchInstanceAt, by the - // master's provider id and the occurrence's original start. - expect(writer.fetchInstanceCalls).toHaveLength(1); - expect(writer.fetchInstanceCalls[0]).toMatchObject({ - calendarId: calendar.providerCalendarId, - seriesProviderEventId: "g-evt-1", - originalStartAt: SECOND_START_UTC, - scheduleKind: "timed", - }); - // The master itself was never fetched and never patched: the single - // patch targets the RESOLVED instance id, with no recurrence key. - expect(writer.fetchEventCalls).toHaveLength(0); - expect(writer.patchCalls).toHaveLength(1); - expect(writer.patchCalls[0]!.providerEventId).toBe("g-inst-1"); - expect(writer.patchCalls[0]!.recurrence).toEqual({ kind: "instance" }); - expect(writer.patchCalls[0]!.invitation).toBe("none"); - expect(writer.patchCalls[0]!.attendees).toEqual([ - attendee("organizer@example.com", "accepted"), - attendee(SELF, "declined"), - ]); - - // Locally: the master's stored guest list is untouched; the answer lives - // on the instance's exception record, carrying the instance's own - // provider identity. - const master = await events.findById(tenantId, principalId, event._id); - expect(master?.content.attendees).toEqual([ - attendee("organizer@example.com", "accepted"), - attendee(SELF, "accepted"), - ]); - expect(master?.providerVersion).toBe("etag-1" as ProviderEventVersion); - const exceptions = await events.findSeriesExceptions( - tenantId, - principalId, - event._id, - ); - expect(exceptions).toHaveLength(1); - expect(exceptions[0]?.providerEventId).toBe("g-inst-1" as ProviderEventId); - expect(exceptions[0]?.providerVersion).toBe( - "etag-inst-2" as ProviderEventVersion, - ); - expect(exceptions[0]?.content.attendees).toEqual([ - attendee("organizer@example.com", "accepted"), - attendee(SELF, "declined"), - ]); - - // Sibling occurrences are untouched: the master still projects 07-14 and - // 07-28, and the answered instant projects from the exception. - const masterRows = await mongo.db - .collection(SYNC_COLLECTIONS.eventOccurrences) - .find({ eventId: event._id }) - .toArray(); - expect( - masterRows.map((row) => (row["startAt"] as Date).toISOString()).sort(), - ).toEqual(["2026-07-14T15:00:00.000Z", "2026-07-28T15:00:00.000Z"]); - const exceptionRows = await mongo.db - .collection(SYNC_COLLECTIONS.eventOccurrences) - .find({ eventId: exceptions[0]?._id }) - .toArray(); - expect( - exceptionRows.map((row) => (row["startAt"] as Date).toISOString()), - ).toEqual([SECOND_START_UTC]); - }); - - it("patches the series master on a scope-all rsvp, never resolving an instance", async () => { - // The other half of the targeting proof: "all events" answers on the - // master itself. - const { tenantId, principalId, calendar, event } = await seedLinked({ - recurrence: { kind: "seriesMaster", rules: weekly3 }, - }); - const command = await rsvpCommand(event, { responseStatus: "declined" }); - const writer = new FakeProviderEventWriter(); - writer.fetchEventResult = { - ...providerSingle("etag-1", [ - attendee("organizer@example.com", "accepted"), - attendee(SELF, "accepted"), - ]), - recurrence: { kind: "seriesMaster", rules: weekly3 }, - }; - writer.patchResult = { - providerEventId: "g-evt-1" as ProviderEventId, - providerVersion: "etag-2", - }; - - const result = await executeProviderRsvp( - deps(writer, connectionsWith(SELF)), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.fetchInstanceCalls).toHaveLength(0); - expect(writer.fetchEventCalls).toHaveLength(1); - expect(writer.patchCalls).toHaveLength(1); - expect(writer.patchCalls[0]!.providerEventId).toBe("g-evt-1"); - // The master's own current rules are re-written unchanged - // (self-describing), mirroring how a "preserve" series edit writes. - expect(writer.patchCalls[0]!.recurrence).toEqual({ - kind: "series", - rules: weekly3, - }); - expect(writer.patchCalls[0]!.attendees).toEqual([ - attendee("organizer@example.com", "accepted"), - attendee(SELF, "declined"), - ]); - const master = await events.findById(tenantId, principalId, event._id); - expect(master?.content.attendees).toEqual([ - attendee("organizer@example.com", "accepted"), - attendee(SELF, "declined"), - ]); - }); - - it("keeps a scope-all rsvp from resurrecting a cancelled occurrence", async () => { - // The commit reprojects through reprojectMaster, so a previously deleted - // occurrence's instant stays excluded. - const { calendar, event } = await seedLinked({ - recurrence: { kind: "seriesMaster", rules: weekly3 }, - }); - await events.upsertException( - event, - SECOND_START_UTC as never, - { - content: event.content, - schedule, - cancelled: true, - providerIdentity: null, - }, - now(), - ); - const command = await rsvpCommand(event, { responseStatus: "declined" }); - const writer = new FakeProviderEventWriter(); - writer.fetchEventResult = { - ...providerSingle("etag-1", [attendee(SELF, "accepted")]), - recurrence: { kind: "seriesMaster", rules: weekly3 }, - }; - - const result = await executeProviderRsvp( - deps(writer, connectionsWith(SELF)), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - const masterRows = await mongo.db - .collection(SYNC_COLLECTIONS.eventOccurrences) - .find({ eventId: event._id }) - .toArray(); - expect( - masterRows.map((row) => (row["startAt"] as Date).toISOString()).sort(), - ).toEqual(["2026-07-14T15:00:00.000Z", "2026-07-28T15:00:00.000Z"]); - }); - - it("confirms a scope-this replay without a second write when the instance already holds the answer", async () => { - const { tenantId, principalId, calendar, event } = await seedLinked({ - recurrence: { kind: "seriesMaster", rules: weekly3 }, - }); - const command = await rsvpCommand(event, { - responseStatus: "declined", - scope: "this", - recurrenceId: SECOND_START_UTC, - }); - const writer = new FakeProviderEventWriter(); - writer.fetchInstanceResult = providerInstance([attendee(SELF, "declined")]); - - const result = await executeProviderRsvp( - deps(writer, connectionsWith(SELF)), - command, - event, - calendar, - now, - ); - - expect(result.outcome.state).toBe("confirmed"); - expect(writer.patchCalls).toHaveLength(0); - expect( - result.outcome.state === "confirmed" && result.outcome.providerVersion, - ).toBe("etag-inst-1" as ProviderEventVersion); - // The already-landed answer still converges locally onto the exception. - const exceptions = await events.findSeriesExceptions( - tenantId, - principalId, - event._id, - ); - expect(exceptions).toHaveLength(1); - expect(exceptions[0]?.content.attendees).toEqual([ - attendee(SELF, "declined"), - ]); - }); -}); diff --git a/packages/sync/src/domain/provider-command.service.ts b/packages/sync/src/domain/provider-command.service.ts deleted file mode 100644 index e27f0bf373..0000000000 --- a/packages/sync/src/domain/provider-command.service.ts +++ /dev/null @@ -1,2419 +0,0 @@ -import { type DateTime, type EventId } from "@core/types/domain-primitives"; -import { - type EditableRecurrence, - type EventSchedule, -} from "@core/types/event.contracts"; -import { type Attendee } from "@core/types/event-attendance.contracts"; -import { type RecurrenceEdit } from "@core/types/event-command.contracts"; -import { type SyncCommandFailureReason } from "@core/types/sync/command.contracts"; -import { - type ProviderEventVersion, - type SyncEventContent, - type SyncEventRecurrence, -} from "@core/types/sync/event.contracts"; -import { - type ConnectionId, - type PrincipalId, - type ProviderEventId, - type ProviderKind, - type TenantId, -} from "@core/types/sync/identity.contracts"; -import { - mergeAttendees, - mergeUpdateContent, - omitNullColor, - resolveUpdateContent, - resolveUpdateSchedule, -} from "@sync/domain/merge-update-content"; -import { - occurrenceScheduleAfterSeriesEdit, - occurrenceScheduleAt, - scheduleStartAt, - truncateRulesBefore, -} from "@sync/domain/occurrence-projection"; -import { - type AccessTokenSource, - resolveAccessToken, - runProviderWrite, -} from "@sync/domain/provider-write-ladder"; -import { reprojectOccurrences } from "@sync/domain/reproject"; -import { - buildRemainderMaster, - deleteFollowingExceptions, - exceptionInstant, - partitionEditAllExceptions, - reprojectMaster, - truncatedSeriesMaster, -} from "@sync/domain/series-exception"; -import { type ProviderEvent } from "@sync/providers/provider-event.port"; -import { - type ProviderEventWriter, - type ProviderWriteRecurrence, - type ProviderWriteResult, -} from "@sync/providers/provider-event-writer.port"; -import { type CommandRecord } from "@sync/storage/contracts/command.contracts"; -import { type EventRecord } from "@sync/storage/contracts/event.contracts"; -import { type ProviderCalendarRecord } from "@sync/storage/contracts/provider-calendar.contracts"; -import { type CommandRepository } from "@sync/storage/repositories/command.repository"; -import { type DeletionMarkerRepository } from "@sync/storage/repositories/deletion-marker.repository"; -import { type EventRepository } from "@sync/storage/repositories/event.repository"; -import { type EventOccurrenceRepository } from "@sync/storage/repositories/event-occurrence.repository"; -import { type SyncResourceRepository } from "@sync/storage/repositories/sync-resource.repository"; - -// The single connection fact the attendee organizer guard needs, narrowed -// from ProviderConnectionRepository so tests can fake it without a database. -export interface ProviderConnectionLookup { - findById( - tenantId: TenantId, - principalId: PrincipalId, - id: ConnectionId, - ): Promise<{ - readonly account: { readonly email: string | null }; - readonly provider: ProviderKind; - } | null>; -} - -export interface ProviderMutationDeps { - commands: CommandRepository; - events: EventRepository; - // The derived occurrence projection, rebuilt (or cleared, on delete) so a - // provider-linked event appears in range queries. - occurrences: EventOccurrenceRepository; - // Reads serve a calendar's active generation, so a create has to ask which - // generation that is rather than assume the calendar has never been repaired. - resources: SyncResourceRepository; - // Which account the write acts as — a guest-list replace is only valid on - // an event that account organizes (see organizerGuardFailure). - connections: ProviderConnectionLookup; - writer: ProviderEventWriter; - custody: AccessTokenSource; -} - -// Delete also needs the deletion-marker store for the tombstone. -export interface ProviderDeleteDeps extends ProviderMutationDeps { - markers: DeletionMarkerRepository; -} - -// Execute a Compass-initiated create against the owning provider, then commit. -// The deterministic providerEventId (= the command's event id) makes the -// provider create idempotent: a replay finds the id already present and the -// adapter reads it back, so a retry after a crash converges without a separate -// reconciliation step. Provider identity is committed to the canonical event -// BEFORE the command is confirmed, so a crash between the two re-runs harmlessly -// and the command is never confirmed from a write we didn't record — the -// executor confirms only from a definitive provider result. -export async function executeProviderCreate( - deps: ProviderMutationDeps, - command: CommandRecord, - calendar: ProviderCalendarRecord, - now: () => Date, -): Promise { - if (command.input.kind !== "create") { - throw new Error("executeProviderCreate requires a create command"); - } - const { input } = command; - - const tokenResult = await resolveAccessToken( - deps.custody, - calendar.connectionId, - ); - if (!tokenResult.ok) { - // A transient refresh failure is retryable, so leave the command pending; a - // revoked or missing credential is terminal. - if (tokenResult.stop.kind === "pending") return command; - return failCommand( - deps, - command, - tokenResult.stop.reason, - calendar.connectionId, - ); - } - const { accessToken } = tokenResult; - - // A create with intended guests (attendeesEdit "replace") merges against an - // EMPTY provider list — the event does not exist yet — so every intended - // guest enters as needsAction. The organizer is not synthesized: Google - // adds the creating account as an accepted organizer-attendee itself, so a - // create body must never be compared to its readback. - const intendedAttendees = - input.attendeesEdit === "replace" - ? mergeAttendees(input.content.attendees, []) - : undefined; - - // Transient failures are safe to retry — the deterministic id keeps the - // eventual retry idempotent. Every other reason is terminal and maps - // straight to a command failure class. - const writeResult = await runProviderWrite(() => - deps.writer.createEvent({ - accessToken, - calendarId: calendar.providerCalendarId, - providerEventId: command.eventId, - content: input.content, - schedule: input.schedule, - recurrence: toProviderWriteRecurrence(input.recurrence), - invitation: input.invitation, - ...(intendedAttendees ? { attendees: intendedAttendees } : {}), - ...(input.createConference ? { createConference: true } : {}), - ...(input.guestsCanInviteOthers !== undefined - ? { guestsCanInviteOthers: input.guestsCanInviteOthers } - : {}), - }), - ); - if (!writeResult.ok) { - if (writeResult.stop.kind === "pending") return command; - return failCommand( - deps, - command, - writeResult.stop.reason, - calendar.connectionId, - ); - } - const result = writeResult.value; - - // Commit the provider identity to the canonical event and project its - // occurrences, then confirm. Both run before confirmation, so a crash leaves - // the command pending and a retry re-runs them idempotently. - // Ask which generation reads will serve this calendar before projecting, so - // a create onto a repaired calendar is visible immediately rather than - // waiting for a pull to reproject it. - const generations = await deps.resources.activeGenerationByCalendar( - command.tenantId, - command.principalId, - [input.calendarId], - ); - const record = buildLinkedEventRecord( - command, - calendar, - result, - now(), - generations.get(input.calendarId) ?? 0, - intendedAttendees, - ); - await deps.events.put(record); - await reprojectOccurrences(deps.occurrences, record, now); - - const confirmed = await deps.commands.updateOutcome( - command.tenantId, - command.principalId, - command._id, - { - state: "confirmed", - providerEventId: result.providerEventId as ProviderEventId, - providerVersion: result.providerVersion as ProviderEventVersion, - }, - command.attemptCount, - ); - return confirmed ?? command; -} - -async function failCommand( - deps: ProviderMutationDeps, - command: CommandRecord, - reason: SyncCommandFailureReason, - connectionId: ConnectionId, -): Promise { - const failed = await deps.commands.updateOutcome( - command.tenantId, - command.principalId, - command._id, - { state: "failed", failureReason: reason }, - command.attemptCount, - ); - if (reason === "authorizationRevoked") { - await deps.custody.discardRevoked(connectionId); - } - return failed ?? command; -} - -// Gate for a guest-list replace: only the organizer's copy of an event -// supports rewriting the attendee array, and v1 rejects non-organizer guest -// editing outright (`guestsCanModify` is a documented follow-up). The STORED -// organizer is compared case-insensitively against the connection's account -// email so a non-organizer replace fails typed (unsupportedCapability) BEFORE -// any provider call, fetch included. A null stored organizer passes — it -// means no organizer has ever been read back (e.g. a Compass-created event -// with no guests yet), so the connection's own account organizes it. -// Unverifiable states (missing connection row, or a connection without an -// account email while an organizer exists) fail closed with the same typed -// reason rather than guessing. Returns the failed command, or null when the -// replace may proceed. -async function organizerGuardFailure( - deps: ProviderMutationDeps, - command: CommandRecord, - event: EventRecord, - connectionId: ConnectionId, -): Promise { - const organizerEmail = event.content.organizer?.email; - if (organizerEmail === undefined) return null; - const connection = await deps.connections.findById( - command.tenantId, - command.principalId, - connectionId, - ); - const accountEmail = connection?.account.email; - if ( - accountEmail != null && - accountEmail.toLowerCase() === organizerEmail.toLowerCase() - ) { - return null; - } - return failCommand(deps, command, "unsupportedCapability", connectionId); -} - -// After a failed override-align patch, continue when the instance is already -// gone (matching deleteEvent's 404-OK). Return a command to stop on; null means -// local cleanup may proceed. The master write may already have landed, so a -// hard fail on a missing override would leave Google mutated and Compass not. -async function resolveFailedOverrideAlign( - deps: ProviderMutationDeps, - command: CommandRecord, - provider: { - accessToken: string; - calendarId: string; - connectionId: ConnectionId; - }, - providerEventId: string, - reason: SyncCommandFailureReason, -): Promise { - if (reason !== "permanentProviderError") { - return failCommand(deps, command, reason, provider.connectionId); - } - const fetchResult = await runProviderWrite(() => - deps.writer.fetchEvent({ - accessToken: provider.accessToken, - calendarId: provider.calendarId, - providerEventId: providerEventId as ProviderEventId, - }), - ); - if (!fetchResult.ok) { - if (fetchResult.stop.kind === "pending") return command; - return failCommand( - deps, - command, - fetchResult.stop.reason, - provider.connectionId, - ); - } - if (fetchResult.value?.kind === "event") { - return failCommand(deps, command, reason, provider.connectionId); - } - return null; -} - -// Build the canonical event for a provider-linked create: same shape as a cloud -// event but with the provider identity the write returned. calendarId stays the -// Sync provider-calendar id (how the command addressed it); the raw provider -// calendar id is only used for the API call. -function buildLinkedEventRecord( - command: CommandRecord, - calendar: ProviderCalendarRecord, - result: ProviderWriteResult, - now: Date, - generation: number, - // The guest list the create actually wrote (merged, all needsAction), so - // the stored record reflects it before the next Google round-trip; absent - // for "preserve"/legacy creates, which store the command content verbatim. - intendedAttendees?: readonly Attendee[], -): EventRecord { - if (command.input.kind !== "create") { - throw new Error("buildLinkedEventRecord requires a create command"); - } - const { input } = command; - const withAttendees = intendedAttendees - ? { ...input.content, attendees: intendedAttendees } - : input.content; - const content = result.conference - ? { ...withAttendees, conference: result.conference } - : withAttendees; - return { - _id: command.eventId, - tenantId: command.tenantId, - principalId: command.principalId, - origin: "compass", - calendarId: input.calendarId, - clientEventId: input.clientEventId, - connectionId: calendar.connectionId, - providerEventId: result.providerEventId as ProviderEventId, - providerVersion: result.providerVersion as ProviderEventVersion, - // The write result carries no provider update time; a later read sets it. - providerUpdatedAt: null, - deliveryState: "confirmed", - providerMetadata: result.icalUid ? { iCalUID: result.icalUid } : null, - content: omitNullColor(content), - schedule: input.schedule, - recurrence: - input.recurrence.kind === "series" - ? { kind: "seriesMaster", rules: input.recurrence.rules } - : { kind: "single" }, - lifecycleState: "active", - // The calendar's active generation, resolved by the caller — NOT a - // hardcoded 0. Reads serve the active generation, so on a calendar a - // repair has already advanced, generation-0 occurrences are invisible. - // That gap used to be left to "the next incremental pull will reproject - // it", which holds only while pulls are running: when the sweeps froze on - // 2026-07-31 the window stayed open for a day and users watched their new - // events save successfully to Google and then vanish from Compass. - generation, - createdAt: now, - updatedAt: now, - confirmedAt: now, - }; -} - -// The provider write port takes the same single|series shape the editable -// recurrence already carries (unlike the stored form, which renames series to -// seriesMaster), so this is a near-identity mapping kept explicit for clarity. -function toProviderWriteRecurrence( - recurrence: EditableRecurrence, -): ProviderWriteRecurrence { - return recurrence.kind === "series" - ? { kind: "series", rules: recurrence.rules } - : { kind: "single" }; -} - -// Apply a Compass-initiated update to an existing provider-linked event. -// -// Replay safety is the hard part: a successful conditional patch changes the -// provider version, so a naive crash-then-retry would re-send the now-stale -// expected version and the provider would reject it as a conflict — misreporting -// an edit that actually landed. So we FETCH the provider's current state first: -// if it already carries this command's intended content, the edit landed on a -// prior attempt and we simply confirm at the current version (no second write). -// Otherwise we patch conditionally; the If-Match precondition turns a genuine -// concurrent external edit into a versionConflict. The content check only gates -// the replay shortcut, so a false miss falls through to the conditional patch -// (a spurious conflict at worst — never a lost external edit). -export async function executeProviderUpdate( - deps: ProviderMutationDeps, - command: CommandRecord, - event: EventRecord, - calendar: ProviderCalendarRecord, - now: () => Date, -): Promise { - if (command.input.kind !== "update") { - throw new Error("executeProviderUpdate requires an update command"); - } - if (!event.connectionId || !event.providerEventId) { - throw new Error("executeProviderUpdate requires a linked event"); - } - const { input } = command; - const providerEventId = event.providerEventId; - const connectionId = event.connectionId; - - // A guest-list replace is only supported for the organizer; a non-organizer - // replace fails typed before any provider call. - if (input.attendeesEdit === "replace") { - const guardFailure = await organizerGuardFailure( - deps, - command, - event, - connectionId, - ); - if (guardFailure) return guardFailure; - } - - const tokenResult = await resolveAccessToken(deps.custody, connectionId); - if (!tokenResult.ok) { - if (tokenResult.stop.kind === "pending") return command; - return failCommand(deps, command, tokenResult.stop.reason, connectionId); - } - const { accessToken } = tokenResult; - - const location = { - accessToken, - calendarId: calendar.providerCalendarId, - providerEventId, - }; - - // Fetch current provider state to detect a replay (our edit already landed) - // and to learn the version to commit. - // A cancellation read means the event no longer exists as a content event — - // there is nothing to update. - const fetchResult = await runProviderWrite(() => - deps.writer.fetchEvent(location), - ); - if (!fetchResult.ok) { - if (fetchResult.stop.kind === "pending") return command; - return failCommand(deps, command, fetchResult.stop.reason, connectionId); - } - const current = - fetchResult.value?.kind === "event" ? fetchResult.value : null; - if (!current) { - return failCommand(deps, command, "permanentProviderError", connectionId); - } - - // Merge so a title/description edit cannot wipe provider-sourced attendees. - // Omitting content or schedule keeps the freshly fetched provider values so - // a content-only booking patch cannot restore a host's concurrent move. - let content = resolveUpdateContent( - event.content, - input.content, - current.content, - ); - const schedule = resolveUpdateSchedule(input.schedule, current.schedule); - // A "replace" merges the intended membership against the FRESHLY FETCHED - // provider list (current.content), never sync's stored record: the Google - // patch replaces the whole attendees array, and merging against a stale - // stored copy would clobber a concurrent RSVP made between syncs. The - // merged list also lands on the local record at commit, so reads reflect - // the edit before the next provider round-trip. - const intendedAttendees = - input.attendeesEdit === "replace" && input.content - ? mergeAttendees(input.content.attendees, current.content.attendees) - : undefined; - if (intendedAttendees) { - content = { ...content, attendees: intendedAttendees }; - } - // Almost always "single" (event.recurrence.kind is single here, so - // "preserve" resolves to single via intendedSeriesRecurrence's own - // fallback) — except a single→series conversion, which writes real rules. - const intendedRecurrence = intendedSeriesRecurrence(input.recurrence, event); - - if (current.providerManaged) { - return executeProviderManagedUpdate( - deps, - command, - event, - current, - content, - input, - intendedRecurrence, - intendedAttendees, - location, - connectionId, - now, - ); - } - - // Replay: the provider already holds this edit, so confirm at its version - // rather than writing again. - if ( - matchesIntendedEdit( - current, - content, - schedule, - intendedRecurrence, - intendedAttendees, - ) - ) { - return commitProviderUpdate( - deps, - command, - event, - content, - current.providerVersion, - now, - ); - } - - const patchResult = await runProviderWrite(() => - deps.writer.patchEvent({ - ...location, - expectedVersion: patchExpectedVersion( - command, - current, - event, - intendedAttendees !== undefined, - ), - content, - schedule, - recurrence: intendedRecurrence, - invitation: input.invitation, - ...(intendedAttendees ? { attendees: intendedAttendees } : {}), - }), - ); - if (!patchResult.ok) { - if (patchResult.stop.kind === "pending") return command; - return failCommand(deps, command, patchResult.stop.reason, connectionId); - } - const result = patchResult.value; - - return commitProviderUpdate( - deps, - command, - event, - content, - result.providerVersion, - now, - ); -} - -// Commit an updated provider event: write the new content/version to the -// canonical record (owner-scoped, non-upsert so a concurrent delete is not -// resurrected), then confirm. A miss means the local event vanished mid-flight, -// so leave the command pending to re-evaluate rather than confirm a gone event. -// recurrence is recomputed (not left as event.recurrence) so a single→series -// conversion actually persists its new rules locally, not just at the provider. -async function commitProviderUpdate( - deps: ProviderMutationDeps, - command: CommandRecord, - event: EventRecord, - content: SyncEventContent, - providerVersion: string, - now: () => Date, - commitOptions?: { - schedule?: EventSchedule; - customizations?: EventRecord["customizations"]; - }, -): Promise { - if (command.input.kind !== "update") { - throw new Error("commitProviderUpdate requires an update command"); - } - const { input } = command; - const updated: EventRecord = { - ...event, - content, - schedule: commitOptions?.schedule ?? input.schedule ?? event.schedule, - recurrence: storedSeriesRecurrence(input.recurrence, event), - providerVersion: providerVersion as ProviderEventVersion, - providerUpdatedAt: null, - deliveryState: "confirmed", - updatedAt: now(), - }; - if (commitOptions && "customizations" in commitOptions) { - updated.customizations = commitOptions.customizations; - } - const applied = await deps.events.replaceExisting(updated); - if (!applied) return command; - await reprojectOccurrences(deps.occurrences, updated, now); - - const confirmed = await deps.commands.updateOutcome( - command.tenantId, - command.principalId, - command._id, - { - state: "confirmed", - providerEventId: event.providerEventId as ProviderEventId, - providerVersion: providerVersion as ProviderEventVersion, - }, - command.attemptCount, - ); - return confirmed ?? command; -} - -// Provider-managed events keep syncing from the provider; Compass overlays -// title, description, and location as customizations and writes only color and -// guest-list changes the provider accepts. -async function executeProviderManagedUpdate( - deps: ProviderMutationDeps, - command: CommandRecord, - event: EventRecord, - current: ProviderEvent, - mergedContent: SyncEventContent, - input: Extract, - intendedRecurrence: ProviderWriteRecurrence, - intendedAttendees: readonly Attendee[] | undefined, - location: { - accessToken: string; - calendarId: string; - providerEventId: string; - }, - connectionId: ConnectionId, - now: () => Date, -): Promise { - if ( - !deepEqual( - resolveUpdateSchedule(input.schedule, current.schedule), - current.schedule, - ) || - intendedRecurrence.kind !== "single" - ) { - return failCommand(deps, command, "unsupportedCapability", connectionId); - } - - const customizations = computeEventCustomizations( - current.content, - mergedContent, - ); - const storedContent = managedStoredContent( - current.content, - mergedContent, - intendedAttendees, - ); - const commitOptions = { - schedule: current.schedule, - customizations, - }; - - if ( - matchesManagedIntendedEdit( - current, - event, - mergedContent, - customizations, - intendedAttendees, - ) - ) { - return commitProviderUpdate( - deps, - command, - event, - storedContent, - current.providerVersion, - now, - commitOptions, - ); - } - - if (!managedProviderSideChange(current, mergedContent, intendedAttendees)) { - return commitProviderUpdate( - deps, - command, - event, - storedContent, - current.providerVersion, - now, - commitOptions, - ); - } - - const patchResult = await runProviderWrite(() => - deps.writer.patchEvent({ - ...location, - expectedVersion: patchExpectedVersion( - command, - current, - event, - intendedAttendees !== undefined, - ), - providerManaged: true, - content: mergedContent, - schedule: current.schedule, - recurrence: { kind: "single" }, - invitation: input.invitation, - ...(intendedAttendees ? { attendees: intendedAttendees } : {}), - }), - ); - if (!patchResult.ok) { - if (patchResult.stop.kind === "pending") return command; - return failCommand(deps, command, patchResult.stop.reason, connectionId); - } - - return commitProviderUpdate( - deps, - command, - event, - storedContent, - patchResult.value.providerVersion, - now, - commitOptions, - ); -} - -function computeEventCustomizations( - providerContent: SyncEventContent, - intended: SyncEventContent, -): EventRecord["customizations"] { - const customizations: { - title?: string; - description?: string; - location?: string | null; - } = {}; - if (intended.title !== providerContent.title) { - customizations.title = intended.title; - } - if (intended.description !== providerContent.description) { - customizations.description = intended.description; - } - if (intended.location !== providerContent.location) { - customizations.location = intended.location; - } - return Object.keys(customizations).length === 0 ? null : customizations; -} - -function managedStoredContent( - providerContent: SyncEventContent, - intended: SyncEventContent, - intendedAttendees: readonly Attendee[] | undefined, -): SyncEventContent { - let stored = omitNullColor( - mergeUpdateContent(providerContent, { - ...providerContent, - color: intended.color, - }), - ); - if (intendedAttendees) { - stored = { ...stored, attendees: intendedAttendees }; - } - return stored; -} - -function customizationsEqual( - left: EventRecord["customizations"], - right: EventRecord["customizations"], -): boolean { - const normalize = (value: EventRecord["customizations"]) => - value === undefined || value === null ? null : value; - return deepEqual(normalize(left), normalize(right)); -} - -function managedProviderSideChange( - current: ProviderEvent, - content: SyncEventContent, - intendedAttendees: readonly Attendee[] | undefined, -): boolean { - const intendedColor = content.color === null ? undefined : content.color; - const currentColor = - current.content.color === null ? undefined : current.content.color; - if (intendedColor !== currentColor) return true; - if (intendedAttendees === undefined) return false; - return !attendeesMatchIntent(current.content.attendees, intendedAttendees); -} - -function matchesManagedIntendedEdit( - current: ProviderEvent, - event: EventRecord, - mergedContent: SyncEventContent, - customizations: EventRecord["customizations"], - intendedAttendees: readonly Attendee[] | undefined, -): boolean { - if (!customizationsEqual(event.customizations, customizations)) return false; - return !managedProviderSideChange(current, mergedContent, intendedAttendees); -} - -// Apply a Compass-initiated scope-"all" edit to a provider-linked recurring -// series — Google's "edit all events in the series". The master is patched with -// the new content, schedule, AND recurrence rules, and its per-instance -// overrides fall away. Kept separate from executeProviderUpdate because the -// local commit is series-aware: it discards override exceptions but preserves -// cancelled tombstones (a deletion must survive an edit) and reprojects the -// master excluding their instants. -// -// Provider-managed events are single-only today (Google Gmail events), so this -// path is unreachable for them; managed customizations live in the single-event -// update path instead. -// -// Replay safety mirrors the single-event path: fetch the provider's current -// master first; if it already carries this edit (content, schedule, and rules), -// a prior attempt landed, so confirm at the current version without re-writing. -// Otherwise patch conditionally on the command's expected version, turning a -// genuine concurrent external edit into a versionConflict. -export async function executeProviderSeriesUpdate( - deps: ProviderMutationDeps, - command: CommandRecord, - master: EventRecord, - calendar: ProviderCalendarRecord, - now: () => Date, -): Promise { - if (command.input.kind !== "update") { - throw new Error("executeProviderSeriesUpdate requires an update command"); - } - if (!master.connectionId || !master.providerEventId) { - throw new Error("executeProviderSeriesUpdate requires a linked event"); - } - if (master.recurrence.kind !== "seriesMaster") { - throw new Error("executeProviderSeriesUpdate requires a series master"); - } - const { input } = command; - const connectionId = master.connectionId; - const providerEventId = master.providerEventId; - const intendedRecurrence = intendedSeriesRecurrence(input.recurrence, master); - - // Same organizer gate as the single-event path: a non-organizer guest-list - // replace fails typed before any provider call. - if (input.attendeesEdit === "replace") { - const guardFailure = await organizerGuardFailure( - deps, - command, - master, - connectionId, - ); - if (guardFailure) return guardFailure; - } - - const tokenResult = await resolveAccessToken(deps.custody, connectionId); - if (!tokenResult.ok) { - if (tokenResult.stop.kind === "pending") return command; - return failCommand(deps, command, tokenResult.stop.reason, connectionId); - } - const { accessToken } = tokenResult; - - const location = { - accessToken, - calendarId: calendar.providerCalendarId, - providerEventId, - }; - - // Fetch the master's current provider state to detect a replay and learn the - // version to commit. A cancellation read means the series no longer exists. - const fetchResult = await runProviderWrite(() => - deps.writer.fetchEvent(location), - ); - if (!fetchResult.ok) { - if (fetchResult.stop.kind === "pending") return command; - return failCommand(deps, command, fetchResult.stop.reason, connectionId); - } - const current = - fetchResult.value?.kind === "event" ? fetchResult.value : null; - if (!current) { - return failCommand(deps, command, "permanentProviderError", connectionId); - } - - let content = resolveUpdateContent( - master.content, - input.content, - current.content, - ); - const schedule = resolveUpdateSchedule(input.schedule, current.schedule); - // Guest membership merges against the freshly fetched master, mirroring the - // single-event path (see executeProviderUpdate). - const intendedAttendees = - input.attendeesEdit === "replace" && input.content - ? mergeAttendees(input.content.attendees, current.content.attendees) - : undefined; - if (intendedAttendees) { - content = { ...content, attendees: intendedAttendees }; - } - - // Replay: the provider already holds this series edit (rules included), so - // confirm at its version rather than writing again. - if ( - matchesIntendedEdit( - current, - content, - schedule, - intendedRecurrence, - intendedAttendees, - ) - ) { - return commitProviderSeriesUpdate( - deps, - command, - master, - content, - intendedAttendees, - current.providerVersion, - now, - { - accessToken, - calendarId: calendar.providerCalendarId, - connectionId, - }, - ); - } - - const patchResult = await runProviderWrite(() => - deps.writer.patchEvent({ - ...location, - expectedVersion: patchExpectedVersion( - command, - current, - master, - intendedAttendees !== undefined, - ), - content, - schedule, - recurrence: intendedRecurrence, - invitation: input.invitation, - ...(intendedAttendees ? { attendees: intendedAttendees } : {}), - }), - ); - if (!patchResult.ok) { - if (patchResult.stop.kind === "pending") return command; - return failCommand(deps, command, patchResult.stop.reason, connectionId); - } - const result = patchResult.value; - - return commitProviderSeriesUpdate( - deps, - command, - master, - content, - intendedAttendees, - result.providerVersion, - now, - { - accessToken, - calendarId: calendar.providerCalendarId, - connectionId, - }, - ); -} - -// Commit a provider series edit-all locally after the provider write lands. -// An edit-all discards per-instance content/time OVERRIDES (they revert to the -// edited series) but KEEPS cancelled tombstones, whose instants are excluded -// from the reprojected master so a deleted occurrence is not resurrected. A -// conversion to a single event drops every exception. -// -// Content/time overrides are aligned at the provider (patched to the edited -// series), then deleted locally BEFORE the master is replaced — the same -// crash-safety ordering the cloud path uses. Google does not drop instance -// overrides when the master is patched, so a later pull would otherwise -// resurrect stale override fields. Patching (not deleting) keeps the -// occurrence confirmed: Google's events.delete on an instance cancels that -// date, and a later pull would tombstone it out of the series. A -// convert-to-single edit still deletes discarded exceptions at the provider -// (they are no longer series members). Clearing locals first also closes the -// convert-to-single retry hole: a retry that read the converted single master -// would take the single-event path, which never cleans exceptions. Gating the -// kept/discarded split on the command's immutable recurrence intent keeps a -// retry classifying identically. A false from replaceExisting means the -// master vanished mid-flight, so leave the command pending rather than -// confirm a gone series. -async function commitProviderSeriesUpdate( - deps: ProviderMutationDeps, - command: CommandRecord, - master: EventRecord, - content: SyncEventContent, - // Present when this edit-all replaced the guest list: the override-align - // patches carry the same merged membership, so a reverted override does not - // keep a stale guest list Google would otherwise leave on it. - intendedAttendees: readonly Attendee[] | undefined, - providerVersion: string, - now: () => Date, - provider: { - accessToken: string; - calendarId: string; - connectionId: ConnectionId; - }, -): Promise { - if (command.input.kind !== "update") { - throw new Error("commitProviderSeriesUpdate requires an update command"); - } - const { input } = command; - const convertsToSingle = input.recurrence.kind === "single"; - const exceptions = await deps.events.findSeriesExceptions( - command.tenantId, - command.principalId, - master._id, - ); - const { kept, discarded } = partitionEditAllExceptions( - exceptions, - convertsToSingle, - ); - - // Align or remove discarded overrides at Google first, then clear local - // copies. Kept cancelled tombstones are not touched at the provider. - for (const exception of discarded) { - const exceptionProviderEventId = exception.providerEventId; - if (exceptionProviderEventId) { - const alignResult = await runProviderWrite(async () => { - if (convertsToSingle) { - await deps.writer.deleteEvent({ - accessToken: provider.accessToken, - calendarId: provider.calendarId, - providerEventId: exceptionProviderEventId, - expectedVersion: null, - invitation: input.invitation, - }); - return; - } - await deps.writer.patchEvent({ - // Revert the override into the edited series without cancelling - // the occurrence. Delete would mark the instance cancelled at Google. - accessToken: provider.accessToken, - calendarId: provider.calendarId, - providerEventId: exceptionProviderEventId, - expectedVersion: null, - content, - schedule: occurrenceScheduleAfterSeriesEdit( - master.schedule, - resolveUpdateSchedule(input.schedule, master.schedule), - exceptionInstant(exception), - ), - recurrence: { kind: "instance" }, - invitation: input.invitation, - ...(intendedAttendees ? { attendees: intendedAttendees } : {}), - }); - }); - if (!alignResult.ok) { - if (alignResult.stop.kind === "pending") return command; - if (convertsToSingle) { - return failCommand( - deps, - command, - alignResult.stop.reason, - provider.connectionId, - ); - } - const stop = await resolveFailedOverrideAlign( - deps, - command, - provider, - exceptionProviderEventId, - alignResult.stop.reason, - ); - if (stop) return stop; - } - } - - await deps.occurrences.replaceForEvent( - exception._id, - exception.generation, - [], - ); - await deps.events.deleteById( - command.tenantId, - command.principalId, - exception._id, - ); - } - - const updated: EventRecord = { - ...master, - content, - schedule: input.schedule ?? master.schedule, - recurrence: storedSeriesRecurrence(input.recurrence, master), - providerVersion: providerVersion as ProviderEventVersion, - providerUpdatedAt: null, - deliveryState: "confirmed", - updatedAt: now(), - }; - const applied = await deps.events.replaceExisting(updated); - if (!applied) return command; - await reprojectOccurrences( - deps.occurrences, - updated, - now, - kept.map(exceptionInstant), - ); - - const confirmed = await deps.commands.updateOutcome( - command.tenantId, - command.principalId, - command._id, - { - state: "confirmed", - providerEventId: master.providerEventId as ProviderEventId, - providerVersion: providerVersion as ProviderEventVersion, - }, - command.attemptCount, - ); - return confirmed ?? command; -} - -// --------------------------------------------------------------------------- -// Provider-linked recurring scopes "this" and "thisAndFollowing". -// -// A this/thisAndFollowing scope operates on ONE instance of a provider series, -// which — unlike a cloud series exception — has no id of its own until -// resolved via writer.fetchInstanceAt. Every executor below: resolves the -// instance (or, for a split, patches the master directly), applies the same -// replay-safe fetch-then-compare pattern executeProviderUpdate/ -// executeProviderSeriesUpdate already use, and commits locally through -// upsertException/reprojectMaster — the exact local-commit shape the cloud -// path's updateCloudOccurrence/deleteCloudOccurrence/*SeriesFollowing already -// use (series-exception.ts), so a provider-linked and a cloud-only -// series converge to the same on-disk shape. -// -// Known deferred gap: un-cancelling a provider instance (a scope-"this" edit -// of an instance the provider already reports as cancelled) is not -// implemented — it fails with permanentProviderError rather than silently -// no-op'ing. Restoring a cancelled Google instance to "confirmed" is a -// distinct provider operation this slice does not need for the common -// edit/delete-a-live-instance path. -// --------------------------------------------------------------------------- - -// Apply a Compass-initiated scope-"this" edit to one occurrence of a -// provider-linked series: resolve the instance's own provider identity, then -// patch IT (never the master) — mirrors executeProviderUpdate's replay-safe -// fetch-then-compare, but against the resolved instance's location. -// -// Provider-managed events are single-only today, so this occurrence path is -// unreachable for them; managed customizations live in the single-event update -// path instead. -export async function executeProviderOccurrenceUpdate( - deps: ProviderMutationDeps, - command: CommandRecord, - master: EventRecord, - calendar: ProviderCalendarRecord, - now: () => Date, -): Promise { - if (command.input.kind !== "update" || command.input.recurrenceId === null) { - throw new Error( - "executeProviderOccurrenceUpdate requires a this-scope update command", - ); - } - if (!master.connectionId || !master.providerEventId) { - throw new Error( - "executeProviderOccurrenceUpdate requires a linked series master", - ); - } - if (master.recurrence.kind !== "seriesMaster") { - throw new Error("executeProviderOccurrenceUpdate requires a series master"); - } - const { input } = command; - const recurrenceId = input.recurrenceId as DateTime; - const connectionId = master.connectionId; - const seriesProviderEventId = master.providerEventId; - - // Guest-list editing is whole-event/whole-series only in v1: a replace on a - // "this" scope has no defined per-occurrence semantics yet, so refuse typed - // rather than silently preserving — dropped intent would read as a - // successful guest edit that never happened. - if (input.attendeesEdit === "replace") { - return failCommand(deps, command, "unsupportedCapability", connectionId); - } - - const tokenResult = await resolveAccessToken(deps.custody, connectionId); - if (!tokenResult.ok) { - if (tokenResult.stop.kind === "pending") return command; - return failCommand(deps, command, tokenResult.stop.reason, connectionId); - } - const { accessToken } = tokenResult; - - const fetchInstanceResult = await runProviderWrite(() => - deps.writer.fetchInstanceAt({ - accessToken, - calendarId: calendar.providerCalendarId, - seriesProviderEventId, - originalStartAt: recurrenceId, - scheduleKind: master.schedule.kind, - }), - ); - if (!fetchInstanceResult.ok) { - if (fetchInstanceResult.stop.kind === "pending") return command; - return failCommand( - deps, - command, - fetchInstanceResult.stop.reason, - connectionId, - ); - } - const instance = - fetchInstanceResult.value?.kind === "event" - ? fetchInstanceResult.value - : null; - // No live instance to override: never materialized at that instant, or - // already cancelled at the provider (see the deferred-gap note above). - if (!instance) { - return failCommand(deps, command, "permanentProviderError", connectionId); - } - - const content = resolveUpdateContent( - master.content, - input.content, - instance.content, - ); - const schedule = resolveUpdateSchedule(input.schedule, instance.schedule); - const providerEventId = instance.providerEventId; - const location = { - accessToken, - calendarId: calendar.providerCalendarId, - providerEventId, - }; - - // An occurrence override is always a standalone provider event, resolved - // off the series by fetchInstanceAt — "instance", not "single": Google - // rejects a `recurrence` key at all on that kind of event (see - // ProviderWriteRecurrence). - if ( - matchesIntendedEdit(instance, content, schedule, { - kind: "instance", - }) - ) { - return commitProviderOccurrenceUpdate( - deps, - command, - master, - recurrenceId, - content, - schedule, - providerEventId, - instance.providerVersion, - now, - ); - } - - const patchResult = await runProviderWrite(() => - deps.writer.patchEvent({ - ...location, - expectedVersion: command.expectedVersion, - content, - schedule, - recurrence: { kind: "instance" }, - invitation: input.invitation, - }), - ); - if (!patchResult.ok) { - if (patchResult.stop.kind === "pending") return command; - return failCommand(deps, command, patchResult.stop.reason, connectionId); - } - const result = patchResult.value; - - return commitProviderOccurrenceUpdate( - deps, - command, - master, - recurrenceId, - content, - schedule, - providerEventId, - result.providerVersion, - now, - ); -} - -// Commit a provider occurrence override locally: upsert the exception -// carrying the INSTANCE's own provider identity (never the master's — see -// upsertException's providerIdentity param), reproject the master to exclude -// that instant, then project the exception's own occurrence. -async function commitProviderOccurrenceUpdate( - deps: ProviderMutationDeps, - command: CommandRecord, - master: EventRecord, - recurrenceId: DateTime, - content: SyncEventContent, - schedule: EventSchedule, - providerEventId: string, - providerVersion: string, - now: () => Date, -): Promise { - if (command.input.kind !== "update") { - throw new Error( - "commitProviderOccurrenceUpdate requires an update command", - ); - } - const exception = await deps.events.upsertException( - master, - recurrenceId, - { - content, - schedule, - cancelled: false, - providerIdentity: { - providerEventId: providerEventId as ProviderEventId, - providerVersion: providerVersion as ProviderEventVersion, - }, - }, - now(), - ); - await reprojectMaster(deps, command, master, now); - await reprojectOccurrences(deps.occurrences, exception, now); - - const confirmed = await deps.commands.updateOutcome( - command.tenantId, - command.principalId, - command._id, - { - state: "confirmed", - providerEventId: providerEventId as ProviderEventId, - providerVersion: providerVersion as ProviderEventVersion, - }, - command.attemptCount, - ); - return confirmed ?? command; -} - -// Apply a Compass-initiated scope-"this" delete to one occurrence of a -// provider-linked series: resolve the instance, delete IT at the provider -// (Google represents this as cancelling that one instance — the series and -// every other instance are untouched), then cancel the local tombstone. -// Idempotent: an already-cancelled or never-materialized instance converges -// to the same local tombstone without a second provider call, mirroring how -// a whole-event delete treats an already-absent target as success. -export async function executeProviderOccurrenceDelete( - deps: ProviderMutationDeps, - command: CommandRecord, - master: EventRecord, - calendar: ProviderCalendarRecord, - now: () => Date, -): Promise { - if (command.input.kind !== "delete" || command.input.recurrenceId === null) { - throw new Error( - "executeProviderOccurrenceDelete requires a this-scope delete command", - ); - } - if (!master.connectionId || !master.providerEventId) { - throw new Error( - "executeProviderOccurrenceDelete requires a linked series master", - ); - } - if (master.recurrence.kind !== "seriesMaster") { - throw new Error("executeProviderOccurrenceDelete requires a series master"); - } - const { input } = command; - const recurrenceId = input.recurrenceId as DateTime; - const connectionId = master.connectionId; - const seriesProviderEventId = master.providerEventId; - - const tokenResult = await resolveAccessToken(deps.custody, connectionId); - if (!tokenResult.ok) { - if (tokenResult.stop.kind === "pending") return command; - return failCommand(deps, command, tokenResult.stop.reason, connectionId); - } - const { accessToken } = tokenResult; - - const fetchInstanceResult = await runProviderWrite(() => - deps.writer.fetchInstanceAt({ - accessToken, - calendarId: calendar.providerCalendarId, - seriesProviderEventId, - originalStartAt: recurrenceId, - scheduleKind: master.schedule.kind, - }), - ); - if (!fetchInstanceResult.ok) { - if (fetchInstanceResult.stop.kind === "pending") return command; - return failCommand( - deps, - command, - fetchInstanceResult.stop.reason, - connectionId, - ); - } - const instance = - fetchInstanceResult.value?.kind === "event" && - fetchInstanceResult.value.providerEventId !== seriesProviderEventId - ? fetchInstanceResult.value - : null; - - if (instance) { - const deleteResult = await runProviderWrite(() => - deps.writer.deleteEvent({ - accessToken, - calendarId: calendar.providerCalendarId, - providerEventId: instance.providerEventId, - // Unconditional, same rationale as the whole-event delete: cancelling - // one instance is not conditioned on its version. - expectedVersion: null, - invitation: input.invitation, - }), - ); - if (!deleteResult.ok) { - if (deleteResult.stop.kind === "pending") return command; - return failCommand(deps, command, deleteResult.stop.reason, connectionId); - } - } - - const exception = await deps.events.upsertException( - master, - recurrenceId, - { - content: master.content, - schedule: occurrenceScheduleAt(master.schedule, recurrenceId), - cancelled: true, - // `null`, not omitted: this exception is provider-linked (master is), - // and omitting would fall back to the master's own providerEventId — - // colliding the provider_event_identity unique index the master - // already occupies. When the instance is already gone at the provider - // there is no live counterpart to record. - providerIdentity: instance - ? { - providerEventId: instance.providerEventId as ProviderEventId, - providerVersion: instance.providerVersion as ProviderEventVersion, - } - : null, - }, - now(), - ); - await reprojectMaster(deps, command, master, now); - await reprojectOccurrences(deps.occurrences, exception, now); - - const confirmed = await deps.commands.updateOutcome( - command.tenantId, - command.principalId, - command._id, - // No live provider target for this command once the instance is - // cancelled — same shape confirmDeletion uses for a whole-event delete. - { state: "confirmed", providerEventId: null, providerVersion: null }, - command.attemptCount, - ); - return confirmed ?? command; -} - -// Apply a Compass-initiated scope-"thisAndFollowing" delete to a -// provider-linked series: truncate the provider master's rules to end before -// the split (Google removes every instance from that point on), drop the -// local exceptions at/after it, and reproject. A split at the series' own -// first occurrence removes the whole series, so it collapses to the existing -// whole-series provider delete. Content/schedule are NOT part of this write — -// only recurrence changes — so the replay check and patch both hold the -// master's own content/schedule fixed. -export async function executeProviderSeriesFollowingDelete( - deps: ProviderDeleteDeps, - command: CommandRecord, - master: EventRecord, - calendar: ProviderCalendarRecord, - now: () => Date, -): Promise { - if (command.input.kind !== "delete" || command.input.recurrenceId === null) { - throw new Error( - "executeProviderSeriesFollowingDelete requires a thisAndFollowing-scope delete command", - ); - } - if (!master.connectionId || !master.providerEventId) { - throw new Error( - "executeProviderSeriesFollowingDelete requires a linked series master", - ); - } - if (master.recurrence.kind !== "seriesMaster") { - throw new Error( - "executeProviderSeriesFollowingDelete requires a series master", - ); - } - const { input } = command; - const connectionId = master.connectionId; - const providerEventId = master.providerEventId; - const splitAt = new Date(input.recurrenceId as DateTime); - - if (splitAt.getTime() <= scheduleStartAt(master.schedule).getTime()) { - return executeProviderDelete(deps, command, master, calendar, now); - } - - const tokenResult = await resolveAccessToken(deps.custody, connectionId); - if (!tokenResult.ok) { - if (tokenResult.stop.kind === "pending") return command; - return failCommand(deps, command, tokenResult.stop.reason, connectionId); - } - const { accessToken } = tokenResult; - - await deleteFollowingExceptions(deps, command, master._id, splitAt); - const truncatedRules = truncateRulesBefore(master.recurrence.rules, splitAt); - const location = { - accessToken, - calendarId: calendar.providerCalendarId, - providerEventId, - }; - - const fetchResult = await runProviderWrite(() => - deps.writer.fetchEvent(location), - ); - if (!fetchResult.ok) { - if (fetchResult.stop.kind === "pending") return command; - return failCommand(deps, command, fetchResult.stop.reason, connectionId); - } - const current = - fetchResult.value?.kind === "event" ? fetchResult.value : null; - if (!current) { - return failCommand(deps, command, "permanentProviderError", connectionId); - } - - const truncateRecurrence: ProviderWriteRecurrence = { - kind: "series", - rules: truncatedRules, - }; - if ( - matchesIntendedEdit( - current, - master.content, - master.schedule, - truncateRecurrence, - ) - ) { - return commitProviderSeriesFollowingDelete( - deps, - command, - master, - splitAt, - current.providerVersion, - now, - ); - } - - const patchResult = await runProviderWrite(() => - deps.writer.patchEvent({ - ...location, - expectedVersion: patchExpectedVersion(command, current, master), - content: master.content, - schedule: master.schedule, - recurrence: truncateRecurrence, - invitation: input.invitation, - }), - ); - if (!patchResult.ok) { - if (patchResult.stop.kind === "pending") return command; - return failCommand(deps, command, patchResult.stop.reason, connectionId); - } - const result = patchResult.value; - - return commitProviderSeriesFollowingDelete( - deps, - command, - master, - splitAt, - result.providerVersion, - now, - ); -} - -async function commitProviderSeriesFollowingDelete( - deps: ProviderMutationDeps, - command: CommandRecord, - master: EventRecord, - splitAt: Date, - providerVersion: string, - now: () => Date, -): Promise { - const truncated: EventRecord = { - ...truncatedSeriesMaster(master, splitAt, now()), - providerVersion: providerVersion as ProviderEventVersion, - providerUpdatedAt: null, - deliveryState: "confirmed", - }; - const applied = await deps.events.replaceExisting(truncated); - if (!applied) return command; - await reprojectMaster(deps, command, truncated, now); - - const confirmed = await deps.commands.updateOutcome( - command.tenantId, - command.principalId, - command._id, - { - state: "confirmed", - providerEventId: master.providerEventId as ProviderEventId, - providerVersion: providerVersion as ProviderEventVersion, - }, - command.attemptCount, - ); - return confirmed ?? command; -} - -// Apply a Compass-initiated scope-"thisAndFollowing" EDIT to a provider-linked -// series by SPLITTING it, mirroring updateCloudSeriesFollowing: truncate the -// original master's rules at the provider (same as the delete twin above), -// then CREATE a new remainder series at the provider carrying the edit, at a -// deterministic provider event id so a retry converges on one remainder -// instead of duplicating it. A split at the series' own first occurrence -// edits the whole series, so it collapses to the existing provider edit-all. -export async function executeProviderSeriesFollowingUpdate( - deps: ProviderMutationDeps, - command: CommandRecord, - master: EventRecord, - calendar: ProviderCalendarRecord, - now: () => Date, -): Promise { - if (command.input.kind !== "update" || command.input.recurrenceId === null) { - throw new Error( - "executeProviderSeriesFollowingUpdate requires a thisAndFollowing-scope update command", - ); - } - if (!master.connectionId || !master.providerEventId) { - throw new Error( - "executeProviderSeriesFollowingUpdate requires a linked series master", - ); - } - if (master.recurrence.kind !== "seriesMaster") { - throw new Error( - "executeProviderSeriesFollowingUpdate requires a series master", - ); - } - const { input } = command; - const connectionId = master.connectionId; - const providerEventId = master.providerEventId; - const splitAt = new Date(input.recurrenceId as DateTime); - - if (splitAt.getTime() <= scheduleStartAt(master.schedule).getTime()) { - return executeProviderSeriesUpdate(deps, command, master, calendar, now); - } - - // Same v1 rule as the occurrence path: a guest-list replace has no defined - // semantics on a thisAndFollowing split (which guest list would the - // truncated original keep?), so refuse typed rather than silently - // preserving. - if (input.attendeesEdit === "replace") { - return failCommand(deps, command, "unsupportedCapability", connectionId); - } - - const tokenResult = await resolveAccessToken(deps.custody, connectionId); - if (!tokenResult.ok) { - if (tokenResult.stop.kind === "pending") return command; - return failCommand(deps, command, tokenResult.stop.reason, connectionId); - } - const { accessToken } = tokenResult; - - // Truncate the original master first — same ordering as the cloud path: - // the worst transient state between this step and the remainder create - // below is a momentary gap at the split, never a duplicate series. - await deleteFollowingExceptions(deps, command, master._id, splitAt); - const truncatedRules = truncateRulesBefore(master.recurrence.rules, splitAt); - const originalLocation = { - accessToken, - calendarId: calendar.providerCalendarId, - providerEventId, - }; - - const fetchResult = await runProviderWrite(() => - deps.writer.fetchEvent(originalLocation), - ); - if (!fetchResult.ok) { - if (fetchResult.stop.kind === "pending") return command; - return failCommand(deps, command, fetchResult.stop.reason, connectionId); - } - const current = - fetchResult.value?.kind === "event" ? fetchResult.value : null; - if (!current) { - return failCommand(deps, command, "permanentProviderError", connectionId); - } - - const truncateRecurrence: ProviderWriteRecurrence = { - kind: "series", - rules: truncatedRules, - }; - let originalVersion: string; - if ( - matchesIntendedEdit( - current, - master.content, - master.schedule, - truncateRecurrence, - ) - ) { - originalVersion = current.providerVersion; - } else { - const truncateResult = await runProviderWrite(() => - deps.writer.patchEvent({ - ...originalLocation, - expectedVersion: patchExpectedVersion(command, current, master), - content: master.content, - schedule: master.schedule, - recurrence: truncateRecurrence, - invitation: input.invitation, - }), - ); - if (!truncateResult.ok) { - if (truncateResult.stop.kind === "pending") return command; - return failCommand( - deps, - command, - truncateResult.stop.reason, - connectionId, - ); - } - originalVersion = truncateResult.value.providerVersion; - } - - const truncated: EventRecord = { - ...truncatedSeriesMaster(master, splitAt, now()), - providerVersion: originalVersion as ProviderEventVersion, - providerUpdatedAt: null, - deliveryState: "confirmed", - }; - const appliedTruncate = await deps.events.replaceExisting(truncated); - if (!appliedTruncate) return command; - await reprojectMaster(deps, command, truncated, now); - - // Remainder comes from the original (pre-truncation) master. "preserve" - // must not use intendedSeriesRecurrence, which would re-write the already - // bounded rules the original just got truncated to. - const remainderDraft = buildRemainderMaster(master, command, now()); - const remainderRecurrence: ProviderWriteRecurrence = - remainderDraft.recurrence.kind === "seriesMaster" - ? { kind: "series", rules: remainderDraft.recurrence.rules } - : { kind: "single" }; - - const createResultAttempt = await runProviderWrite(() => - deps.writer.createEvent({ - accessToken, - calendarId: calendar.providerCalendarId, - providerEventId: remainderDraft._id, - content: remainderDraft.content, - schedule: remainderDraft.schedule, - recurrence: remainderRecurrence, - invitation: input.invitation, - }), - ); - if (!createResultAttempt.ok) { - if (createResultAttempt.stop.kind === "pending") return command; - return failCommand( - deps, - command, - createResultAttempt.stop.reason, - connectionId, - ); - } - const createResult = createResultAttempt.value; - - const remainder: EventRecord = { - ...remainderDraft, - providerEventId: createResult.providerEventId as ProviderEventId, - providerVersion: createResult.providerVersion as ProviderEventVersion, - providerUpdatedAt: null, - deliveryState: "confirmed", - }; - await deps.events.put(remainder); - await reprojectOccurrences(deps.occurrences, remainder, now); - - const confirmed = await deps.commands.updateOutcome( - command.tenantId, - command.principalId, - command._id, - { - state: "confirmed", - providerEventId: createResult.providerEventId as ProviderEventId, - providerVersion: createResult.providerVersion as ProviderEventVersion, - }, - command.attemptCount, - ); - return confirmed ?? command; -} - -// --------------------------------------------------------------------------- -// RSVP. -// -// An RSVP is not a content edit: it rewrites exactly ONE attendee entry — the -// connection account's own, matched case-insensitively by email — and leaves -// every other entry byte-identical to the freshly fetched provider state. -// Because a Google patch replaces the WHOLE attendees array, the write sends -// the full merged list, and sendUpdates is always "none": answering an -// invitation must never email the guest list. -// --------------------------------------------------------------------------- - -// The caller's own attendee entry, matched case-insensitively by the -// connection's account email. Alias emails not matching is a named wart. -function findSelfAttendee( - attendees: readonly Attendee[], - accountEmail: string, -): Attendee | undefined { - const email = accountEmail.toLowerCase(); - return attendees.find((attendee) => attendee.email.toLowerCase() === email); -} - -// Execute a Compass-initiated rsvp command against the owning provider. -// -// Targeting: scope "all" (and any single event) addresses the event itself — -// for a recurring series that is the SERIES MASTER, so the answer covers -// every occurrence. Scope "this" addresses ONE occurrence: the Google -// instance is resolved via the writer's fetchInstanceAt (the same -// occurrence-id decode update/delete use — an instance id is never -// hand-built here) and IT is patched, leaving the master and every sibling -// instance untouched. -// -// Replay safety: the freshly fetched self entry already holding the intended -// status means a prior attempt landed (or the user answered from another -// client) — confirm at the current provider version without a second write. -// The patch itself is UNCONDITIONAL (no If-Match): any other guest's -// concurrent RSVP bumps the provider version, and RSVP drift must never -// block an RSVP. The fetch→patch window this leaves open is the pack's -// named clobber-window wart, same as attendeesEdit "replace". -// -// Guards, all typed unsupportedCapability: an unverifiable connection -// (missing row / no account email) and a stored attendee list without the -// self entry both fail closed BEFORE any provider call; a fetched list -// without the self entry (removed provider-side since the last pull) fails -// after the fetch, without a write. There is deliberately NO organizer -// guard: the organizer RSVPing their own event is allowed — Google lists -// the organizer as an attendee of their own event. -export async function executeProviderRsvp( - deps: ProviderMutationDeps, - command: CommandRecord, - event: EventRecord, - calendar: ProviderCalendarRecord, - now: () => Date, -): Promise { - if (command.input.kind !== "rsvp") { - throw new Error("executeProviderRsvp requires an rsvp command"); - } - if (!event.connectionId || !event.providerEventId) { - throw new Error("executeProviderRsvp requires a linked event"); - } - const { input } = command; - const connectionId = event.connectionId; - const seriesProviderEventId = event.providerEventId; - - const connection = await deps.connections.findById( - command.tenantId, - command.principalId, - connectionId, - ); - const accountEmail = connection?.account.email ?? null; - if (accountEmail === null) { - return failCommand(deps, command, "unsupportedCapability", connectionId); - } - if (!findSelfAttendee(event.content.attendees, accountEmail)) { - return failCommand(deps, command, "unsupportedCapability", connectionId); - } - - const tokenResult = await resolveAccessToken(deps.custody, connectionId); - if (!tokenResult.ok) { - if (tokenResult.stop.kind === "pending") return command; - return failCommand(deps, command, tokenResult.stop.reason, connectionId); - } - const { accessToken } = tokenResult; - - const perOccurrence = - input.scope === "this" && - input.recurrenceId !== null && - event.recurrence.kind === "seriesMaster"; - - // Fetch the target's current provider state: the master (or single event) - // itself, or the ONE resolved instance for a per-occurrence answer. - const fetchResult = await runProviderWrite(() => - perOccurrence - ? deps.writer.fetchInstanceAt({ - accessToken, - calendarId: calendar.providerCalendarId, - seriesProviderEventId, - originalStartAt: input.recurrenceId as DateTime, - scheduleKind: event.schedule.kind, - }) - : deps.writer.fetchEvent({ - accessToken, - calendarId: calendar.providerCalendarId, - providerEventId: seriesProviderEventId, - }), - ); - if (!fetchResult.ok) { - if (fetchResult.stop.kind === "pending") return command; - return failCommand(deps, command, fetchResult.stop.reason, connectionId); - } - const current = - fetchResult.value?.kind === "event" ? fetchResult.value : null; - // Nothing live to answer: the event (or that one instance) no longer - // exists as a content event at the provider. - if (!current) { - return failCommand(deps, command, "permanentProviderError", connectionId); - } - - const selfIndex = current.content.attendees.findIndex( - (attendee) => attendee.email.toLowerCase() === accountEmail.toLowerCase(), - ); - // Removed from the guest list provider-side since the last pull: there is - // no self entry to rewrite (same typed refusal as the stored-list guard, - // just discovered one step later — after the fetch, before any write). - if (selfIndex === -1) { - return failCommand(deps, command, "unsupportedCapability", connectionId); - } - const currentSelf = current.content.attendees[selfIndex] as Attendee; - const alreadyAnswered = currentSelf.responseStatus === input.responseStatus; - - const intendedAttendees = alreadyAnswered - ? current.content.attendees - : current.content.attendees.map((attendee, index) => - index === selfIndex - ? { ...attendee, responseStatus: input.responseStatus } - : attendee, - ); - - let providerVersion = current.providerVersion; - if (!alreadyAnswered) { - const patchResult = await runProviderWrite(() => - deps.writer.patchEvent({ - accessToken, - calendarId: calendar.providerCalendarId, - providerEventId: current.providerEventId, - // Unconditional on purpose: another guest's concurrent RSVP bumps - // the provider version, and RSVP drift must never block an RSVP. - expectedVersion: null, - content: rsvpEchoContent(current.content), - schedule: current.schedule, - recurrence: rsvpWriteRecurrence(current.recurrence), - // Answering an invitation never emails anyone. - invitation: "none", - attendees: intendedAttendees, - }), - ); - if (!patchResult.ok) { - if (patchResult.stop.kind === "pending") return command; - return failCommand(deps, command, patchResult.stop.reason, connectionId); - } - providerVersion = patchResult.value.providerVersion; - } - - if (perOccurrence) { - return commitProviderOccurrenceRsvp( - deps, - command, - event, - input.recurrenceId as DateTime, - { ...current.content, attendees: intendedAttendees }, - current.schedule, - current.providerEventId, - providerVersion, - now, - ); - } - return commitProviderRsvp( - deps, - command, - event, - intendedAttendees, - providerVersion, - now, - ); -} - -// The non-attendee body an rsvp patch sends. The write port requires a full -// body, so the freshly fetched provider state is echoed back — re-writing -// the provider's own current values is self-describing (mirroring how a -// "preserve" series edit re-writes the current rules) and the only field -// that actually changes is the self entry riding the separate `attendees` -// input. color/colorHex are STRIPPED rather than echoed: colorHex is -// read-only, and a slot color in the body would trigger the writer's -// label-clearing pre-patch (an extra provider round-trip that could clear a -// label Compass never meant to touch) — an omitted color leaves Google's -// color state entirely alone under patch merge-by-key semantics. -function rsvpEchoContent(content: SyncEventContent): SyncEventContent { - const { color: _color, colorHex: _colorHex, ...rest } = content; - return rest; -} - -// The recurrence an rsvp patch re-writes, mapped from the fetched read: a -// series master re-writes its own current rules (harmless, self-describing); -// a resolved instance must OMIT the recurrence key entirely (Google rejects -// one on an instance — see ProviderWriteRecurrence); a single event stays -// single. -function rsvpWriteRecurrence( - current: ProviderEvent["recurrence"], -): ProviderWriteRecurrence { - if (current.kind === "seriesMaster") { - return { kind: "series", rules: current.rules }; - } - if (current.kind === "instance") return { kind: "instance" }; - return { kind: "single" }; -} - -// Commit a confirmed whole-event (or whole-series) rsvp locally: rewrite the -// stored record's attendee list — only that; pulls own every other field — -// bump the provider version, and reproject through reprojectMaster so a -// series' exception instants stay excluded (a cancelled occurrence must not -// be resurrected by an RSVP). A miss from replaceExisting means the event -// vanished mid-flight — leave the command pending rather than confirm -// against a gone event. -async function commitProviderRsvp( - deps: ProviderMutationDeps, - command: CommandRecord, - event: EventRecord, - attendees: readonly Attendee[], - providerVersion: string, - now: () => Date, -): Promise { - const updated: EventRecord = { - ...event, - content: { ...event.content, attendees }, - providerVersion: providerVersion as ProviderEventVersion, - providerUpdatedAt: null, - deliveryState: "confirmed", - updatedAt: now(), - }; - const applied = await deps.events.replaceExisting(updated); - if (!applied) return command; - await reprojectMaster(deps, command, updated, now); - - const confirmed = await deps.commands.updateOutcome( - command.tenantId, - command.principalId, - command._id, - { - state: "confirmed", - providerEventId: event.providerEventId as ProviderEventId, - providerVersion: providerVersion as ProviderEventVersion, - }, - command.attemptCount, - ); - return confirmed ?? command; -} - -// Commit a confirmed per-occurrence rsvp locally: upsert the exception -// carrying the INSTANCE's own provider identity and its fetched content with -// the rewritten self entry (what a pull of that instance would store), -// reproject the master to exclude that instant, then project the exception's -// own occurrence — the same local-commit shape a scope-"this" edit uses, so -// the next backend read reflects the answer before Google round-trips. -async function commitProviderOccurrenceRsvp( - deps: ProviderMutationDeps, - command: CommandRecord, - master: EventRecord, - recurrenceId: DateTime, - content: SyncEventContent, - schedule: EventSchedule, - providerEventId: string, - providerVersion: string, - now: () => Date, -): Promise { - const exception = await deps.events.upsertException( - master, - recurrenceId, - { - content, - schedule, - cancelled: false, - providerIdentity: { - providerEventId: providerEventId as ProviderEventId, - providerVersion: providerVersion as ProviderEventVersion, - }, - }, - now(), - ); - await reprojectMaster(deps, command, master, now); - await reprojectOccurrences(deps.occurrences, exception, now); - - const confirmed = await deps.commands.updateOutcome( - command.tenantId, - command.principalId, - command._id, - { - state: "confirmed", - providerEventId: providerEventId as ProviderEventId, - providerVersion: providerVersion as ProviderEventVersion, - }, - command.attemptCount, - ); - return confirmed ?? command; -} - -// The provider recurrence a series edit-all writes. "series" sets new rules; -// "single" removes recurrence (converting the series to one event); "preserve" -// re-writes the master's current rules unchanged (harmless, keeps the write -// self-describing). -function intendedSeriesRecurrence( - recurrence: RecurrenceEdit, - master: EventRecord, -): ProviderWriteRecurrence { - if (recurrence.kind === "series") { - return { kind: "series", rules: recurrence.rules }; - } - if (recurrence.kind === "single") return { kind: "single" }; - return master.recurrence.kind === "seriesMaster" - ? { kind: "series", rules: master.recurrence.rules } - : { kind: "single" }; -} - -// The stored recurrence a series edit-all applies to the local master, mirroring -// intendedSeriesRecurrence in the canonical event's own union. -function storedSeriesRecurrence( - recurrence: RecurrenceEdit, - master: EventRecord, -): SyncEventRecurrence { - if (recurrence.kind === "series") { - return { kind: "seriesMaster", rules: recurrence.rules }; - } - if (recurrence.kind === "single") return { kind: "single" }; - return master.recurrence; -} - -// Whether the provider's current event already carries this command's intended -// edit — the signal that a prior attempt landed and this is a safe replay. -// Compares ONLY the fields a patch actually writes (title, description, -// location, color, schedule, recurrence — and the guest membership, but only -// when the command intends a guest-list replace). organizer/conference are -// read-reflected, never written by the provider adapter, so they drift -// independently — comparing them would turn a landed edit into a false miss, -// then a stale-version patch, then a spurious versionConflict on a write that -// already succeeded. Attendees are the same by default (an attendee RSVPs -// whenever they like), so they stay out of the comparison for every -// "preserve"/legacy command; a "replace" command DOES write them, so its -// replay check compares membership — as email sets, order-insensitive and -// responseStatus-ignored, because RSVP drift must never block replay (see -// attendeesMatchIntent). Recurrence IS written (a series edit-all changes the -// rules), so it must be compared: a rules-only edit leaves content and -// schedule identical, and without this a false replay would confirm the -// command without ever writing the new rules. A false negative on the -// compared fields is still safe: the replay check falls through to the -// conditional patch, and patchExpectedVersion keeps the submitter's version. -function matchesIntendedEdit( - current: ProviderEvent, - content: SyncEventContent, - schedule: EventSchedule, - recurrence: ProviderWriteRecurrence, - intendedAttendees?: readonly Attendee[], -): boolean { - // Null on the command means "no color"; treat it like an absent color on - // the provider read so a clear that already landed counts as a replay. - const intendedColor = content.color === null ? undefined : content.color; - return ( - current.content.title === content.title && - current.content.description === content.description && - current.content.location === content.location && - current.content.color === intendedColor && - deepEqual(current.schedule, schedule) && - recurrenceMatches(current.recurrence, recurrence) && - attendeesMatchIntent(current.content.attendees, intendedAttendees) - ); -} - -// The If-Match version for a conditional patch on a fetched provider event. -// The command's expectedVersion is the version its submitter last saw, so a -// patch conditioned on it refuses to overwrite an edit made elsewhere since. -// But a provider can rotate an event's version on its own (Exchange rewrites -// an item's change key seconds after a create; see #3208 and the -// live-provider-smoke), and until the next pull refreshes the stored version -// every conditional edit would fail as a spurious versionConflict. When the -// fetched event still carries exactly what Compass has stored for it (the -// written fields, per matchesIntendedEdit; guest membership only when this -// command replaces it), nobody edited it elsewhere, so the drift is the -// provider's own rotation and the patch conditions on the fresh version. Any -// difference keeps the submitter's version, so a genuine external edit still -// fails as versionConflict. An unconditional command (null) stays -// unconditional. Occurrence patches have no stored instance to compare -// against, so they keep the submitter's version as before. -function patchExpectedVersion( - command: CommandRecord, - current: ProviderEvent, - stored: EventRecord, - compareAttendees = false, -): string | null { - if (command.expectedVersion === null) return null; - const unchanged = matchesIntendedEdit( - current, - stored.content, - stored.schedule, - storedWriteRecurrence(stored.recurrence), - compareAttendees ? stored.content.attendees : undefined, - ); - return unchanged ? current.providerVersion : command.expectedVersion; -} - -// The recurrence a patch would write for a stored record as it stands: a -// series master carries its rules, an exception addresses one instance, and -// anything else is a single event. -function storedWriteRecurrence( - recurrence: EventRecord["recurrence"], -): ProviderWriteRecurrence { - if (recurrence.kind === "seriesMaster") { - return { kind: "series", rules: recurrence.rules }; - } - if (recurrence.kind === "exception") return { kind: "instance" }; - return { kind: "single" }; -} - -// Membership comparison for the replay check, entered ONLY when the command -// intends a guest-list replace (undefined = attendees are not part of this -// write; always a match). Email sets, case-insensitive, order-insensitive, -// responseStatus-ignored: a guest RSVPing (or Google reordering the list) -// between a landed patch and its retry must still read as a replay — RSVP -// drift never blocks replay. -function attendeesMatchIntent( - current: readonly Attendee[], - intended: readonly Attendee[] | undefined, -): boolean { - if (intended === undefined) return true; - const currentEmails = new Set( - current.map(({ email }) => email.toLowerCase()), - ); - const intendedEmails = new Set( - intended.map(({ email }) => email.toLowerCase()), - ); - return ( - currentEmails.size === intendedEmails.size && - [...intendedEmails].every((email) => currentEmails.has(email)) - ); -} - -// Whether the provider's reported recurrence matches the recurrence a write -// would set. A single write expects a non-recurring event; a series write -// expects the same rules the provider now reports on its master. -// -// Rules are compared canonically, not byte-for-byte. A provider can echo a rule -// it stored in a reformatted-but-equivalent form (reordered components, -// different casing), so an exact compare would false-miss a landed edit — and -// because this gates a replay, that miss would re-patch with a now-stale -// expected version and fail a write that already succeeded, leaving the local -// event diverged from the provider. Canonicalizing absorbs that cosmetic drift; -// genuinely different rules still differ, so this never widens a real change -// into a false match. Named wart: an exotic reformatting the canonicalization -// misses (e.g. a provider injecting a non-default WKST we never sent) can still -// false-miss — acceptable because Compass emits simple rules and the only -// consequence is a spurious conflict in the narrow landed-then-retried window. -function recurrenceMatches( - current: ProviderEvent["recurrence"], - intended: ProviderWriteRecurrence, -): boolean { - // "single" and "instance" both address a non-recurring event; only "series" - // carries rules to compare, so anything else is a bare kind match. - if (intended.kind !== "series") return current.kind === intended.kind; - if (current.kind !== "seriesMaster") return false; - if (current.rules.length !== intended.rules.length) return false; - const currentCanonical = current.rules.map(canonicalRule).sort(); - const intendedCanonical = intended.rules.map(canonicalRule).sort(); - return currentCanonical.every( - (rule, index) => rule === intendedCanonical[index], - ); -} - -// A rule normalized for equality: uppercased, its optional RRULE: prefix -// dropped, and its ;-separated components sorted. This makes the comparison -// order- and case-insensitive without a full RRULE parse (which would drag in -// dtstart defaulting and reformatting of its own). -function canonicalRule(rule: string): string { - return rule - .trim() - .toUpperCase() - .replace(/^RRULE:/, "") - .split(";") - .filter((part) => part.length > 0) - .sort() - .join(";"); -} - -function deepEqual(a: unknown, b: unknown): boolean { - if (a === b) return true; - if ( - typeof a !== "object" || - typeof b !== "object" || - a === null || - b === null - ) { - return false; - } - if (Array.isArray(a) !== Array.isArray(b)) return false; - const aKeys = Object.keys(a); - const bKeys = Object.keys(b); - if (aKeys.length !== bKeys.length) return false; - return aKeys.every((key) => - deepEqual( - (a as Record)[key], - (b as Record)[key], - ), - ); -} - -// Delete a Compass-initiated provider event. The event is marked deletionPending -// (so it reads as "deleting" while the command is in flight or retrying) BEFORE -// the provider is asked, and its local content is removed only AFTER the -// provider confirms — never delete content before provider confirmation. The -// delete is unconditional: the user's intent to cancel does not hinge on a -// version, and a routine attendee RSVP must not block it. Idempotent: the -// adapter treats an already-absent event as deleted, and the marker + local -// delete are both idempotent, so a retry after a crash converges. -export async function executeProviderDelete( - deps: ProviderDeleteDeps, - command: CommandRecord, - event: EventRecord, - calendar: ProviderCalendarRecord, - now: () => Date, -): Promise { - if (command.input.kind !== "delete") { - throw new Error("executeProviderDelete requires a delete command"); - } - if (!event.connectionId || !event.providerEventId) { - throw new Error("executeProviderDelete requires a linked event"); - } - const { input } = command; - const connectionId = event.connectionId; - const providerEventId = event.providerEventId; - - // Mark the event as being deleted. If it is already gone locally, a prior - // attempt removed it (the marker was written first) — the delete converged. - // Still cascade any leftover series exceptions: older builds removed the - // master without clearing overrides, and a crash between exception cleanup - // and confirm can leave the same residue. - const marked = await deps.events.replaceExisting({ - ...event, - lifecycleState: "deletionPending", - updatedAt: now(), - }); - if (!marked) { - await clearSeriesExceptions(deps, command, command.eventId); - return confirmDeletion(deps, command); - } - - const tokenResult = await resolveAccessToken(deps.custody, connectionId); - if (!tokenResult.ok) { - if (tokenResult.stop.kind === "pending") return command; - return revertAndFail( - deps, - command, - event, - tokenResult.stop.reason, - connectionId, - now, - ); - } - const { accessToken } = tokenResult; - - // Transient: keep the event deletionPending (visibly deleting) and retry. - // Terminal: the delete failed, so restore the event to active rather than - // leaving it stuck showing "deleting". - const deleteResult = await runProviderWrite(() => - deps.writer.deleteEvent({ - accessToken, - calendarId: calendar.providerCalendarId, - providerEventId, - // Unconditional: a cancellation is not conditioned on the version, so an - // unrelated external change never blocks it. - expectedVersion: null, - invitation: input.invitation, - }), - ); - if (!deleteResult.ok) { - if (deleteResult.stop.kind === "pending") return command; - return revertAndFail( - deps, - command, - event, - deleteResult.stop.reason, - connectionId, - now, - ); - } - - // The provider confirmed the deletion. Write the content-free tombstone first - // (so no window exists where the event is gone with no marker), clear the - // master's occurrences, cascade local series exceptions (Google-side instance - // overrides), then remove the master LAST — same crash-safety as the cloud - // series delete / pull cascade. Clearing before deleteById matters: a crash - // after deleteById would otherwise strand occurrence rows, since the retry's - // already-gone (`!marked`) branch confirms without ever clearing them. - await deps.markers.record({ - tenantId: event.tenantId, - principalId: event.principalId, - connectionId, - calendarId: event.calendarId, - providerEventId, - providerVersion: event.providerVersion, - deletionSource: "compass", - deletedAt: now(), - }); - await deps.occurrences.replaceForEvent(event._id, event.generation, []); - await clearSeriesExceptions(deps, command, event._id); - await deps.events.deleteById(event.tenantId, event.principalId, event._id); - return confirmDeletion(deps, command); -} - -// Remove every local exception of a series (occurrences first). Idempotent: -// findSeriesExceptions is empty when the target was a single event or when a -// prior attempt already cleared overrides. Does not call the provider — Google -// series delete already discarded the instances with the master. -async function clearSeriesExceptions( - deps: ProviderDeleteDeps, - command: CommandRecord, - seriesId: EventId, -): Promise { - const exceptions = await deps.events.findSeriesExceptions( - command.tenantId, - command.principalId, - seriesId, - ); - for (const exception of exceptions) { - await deps.occurrences.replaceForEvent( - exception._id, - exception.generation, - [], - ); - await deps.events.deleteById( - command.tenantId, - command.principalId, - exception._id, - ); - } -} - -// Confirm a completed deletion: the event has no live provider target anymore, -// so the confirmed outcome carries no provider identity. -async function confirmDeletion( - deps: ProviderMutationDeps, - command: CommandRecord, -): Promise { - const confirmed = await deps.commands.updateOutcome( - command.tenantId, - command.principalId, - command._id, - { state: "confirmed", providerEventId: null, providerVersion: null }, - command.attemptCount, - ); - return confirmed ?? command; -} - -// Restore a deletionPending event to active, then fail the command. A failed -// delete must not leave the event stuck reading as "deleting". The revert write -// is NOT wrapped in a catch: replaceExisting signals the benign "already gone" -// case with a resolved false (not a throw), so a throw here is a real error — -// letting it propagate keeps the command pending (not falsely failed) and -// retryable, rather than marking it terminally failed with a stuck event. -async function revertAndFail( - deps: ProviderMutationDeps, - command: CommandRecord, - event: EventRecord, - reason: SyncCommandFailureReason, - connectionId: ConnectionId, - now: () => Date, -): Promise { - await deps.events.replaceExisting({ - ...event, - lifecycleState: "active", - updatedAt: now(), - }); - return failCommand(deps, command, reason, connectionId); -} diff --git a/packages/sync/src/domain/provider-command.update-managed.db.test.ts b/packages/sync/src/domain/provider-command.update-managed.db.test.ts new file mode 100644 index 0000000000..41f68dfa45 --- /dev/null +++ b/packages/sync/src/domain/provider-command.update-managed.db.test.ts @@ -0,0 +1,272 @@ +import { faker } from "@faker-js/faker"; +import { type DateTime, type TimeZone } from "@core/types/domain-primitives"; +import { type SyncCommandInput } from "@core/types/sync/command.contracts"; +import { type ProviderEventVersion } from "@core/types/sync/event.contracts"; +import { type ProviderEventId } from "@core/types/sync/identity.contracts"; +import { + bindCommandRepos, + COMMAND_NOW, + FakeProviderEventWriter, + newCommandIds, + seedCommandCalendar, + seedLinkedEvent, + stubConnectionLookup, + tokenSource, +} from "@sync/__tests__/helpers/command-scenario"; +import { setupSyncStorage } from "@sync/__tests__/helpers/storage"; +import { executeProviderUpdate } from "@sync/domain/provider-command.update"; +import { type ProviderEvent } from "@sync/providers/provider-event.port"; +import { type EventRecord } from "@sync/storage/contracts/event.contracts"; +import { type CommandRepository } from "@sync/storage/repositories/command.repository"; +import { type CredentialRepository } from "@sync/storage/repositories/credential.repository"; +import { type DeletionMarkerRepository } from "@sync/storage/repositories/deletion-marker.repository"; +import { type EventRepository } from "@sync/storage/repositories/event.repository"; +import { type EventOccurrenceRepository } from "@sync/storage/repositories/event-occurrence.repository"; +import { type ProviderCalendarRepository } from "@sync/storage/repositories/provider-calendar.repository"; +import { type SyncResourceRepository } from "@sync/storage/repositories/sync-resource.repository"; +import { type SyncMongoService } from "@sync/storage/sync-mongo.service"; +import { beforeEach, describe, expect, it } from "bun:test"; + +const storage = setupSyncStorage(import.meta.url); +const repos = bindCommandRepos(storage); +const _objectId = () => faker.database.mongodbObjectId(); +const now = COMMAND_NOW; + +let _mongo: SyncMongoService; +let commands: CommandRepository; +let events: EventRepository; +let occurrences: EventOccurrenceRepository; +let resources: SyncResourceRepository; +let calendars: ProviderCalendarRepository; +let _markers: DeletionMarkerRepository; +let _credentials: CredentialRepository; + +beforeEach(() => { + _mongo = repos.mongo; + commands = repos.commands; + events = repos.events; + occurrences = repos.occurrences; + resources = repos.resources; + calendars = repos.calendars; + _markers = repos.markers; + _credentials = repos.credentials; +}); + +describe("executeProviderUpdate on provider-managed events", () => { + const schedule = { + kind: "timed" as const, + start: "2026-07-14T09:00:00-06:00" as DateTime, + end: "2026-07-14T10:00:00-06:00" as DateTime, + timeZone: "America/Denver" as TimeZone, + }; + const content = (title: string, extras: Record = {}) => ({ + title, + description: "", + location: null, + organizer: null, + attendees: [], + conference: null, + ...extras, + }); + const providerEvent = ( + title: string, + version: string, + extras: Partial = {}, + ): ProviderEvent => ({ + kind: "event", + providerEventId: "g-evt-1" as ProviderEventId, + providerVersion: version, + providerUpdatedAt: null, + content: content(title), + schedule, + busy: true, + recurrence: { kind: "single" }, + providerManaged: true, + ...extras, + }); + + const seedManaged = async ( + inputOverrides: { + commandTitle?: string; + commandSchedule?: typeof schedule; + commandContent?: Record; + eventCustomizations?: EventRecord["customizations"]; + } = {}, + ) => { + const ids = newCommandIds(); + const calendar = await seedCommandCalendar(calendars, ids); + const event = await seedLinkedEvent(events, { + ids, + calendarId: calendar._id, + content: content("Provider title"), + schedule, + recurrence: { kind: "single" }, + now: now(), + }); + if (inputOverrides.eventCustomizations !== undefined) { + await events.replaceExisting({ + ...event, + customizations: inputOverrides.eventCustomizations, + }); + } + const commandTitle = inputOverrides.commandTitle ?? "Compass title"; + const { record: command } = await commands.submit({ + tenantId: ids.tenantId, + principalId: ids.principalId, + idempotencyKey: ids.idempotencyKey, + eventId: event._id, + input: { + kind: "update", + invitation: "none", + content: { + ...content(commandTitle), + ...inputOverrides.commandContent, + }, + schedule: inputOverrides.commandSchedule ?? schedule, + recurrence: { kind: "preserve" }, + scope: "all", + } as unknown as SyncCommandInput, + expectedVersion: "etag-1" as never, + }); + const storedEvent = await events.findById( + ids.tenantId, + ids.principalId, + event._id, + ); + if (!storedEvent) throw new Error("seed failed"); + return { + tenantId: ids.tenantId, + principalId: ids.principalId, + calendar, + event: storedEvent, + command, + }; + }; + + it("stores a title customization without calling patchEvent", async () => { + const { tenantId, principalId, calendar, event, command } = + await seedManaged(); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerEvent("Provider title", "etag-1"); + + const result = await executeProviderUpdate( + { + commands, + events, + occurrences, + resources, + connections: stubConnectionLookup(), + writer, + custody: tokenSource(), + }, + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(0); + const stored = await events.findById(tenantId, principalId, event._id); + expect(stored?.content.title).toBe("Provider title"); + expect(stored?.customizations).toEqual({ title: "Compass title" }); + }); + + it("patches color with providerManaged and confirms at the returned version", async () => { + const { tenantId, principalId, calendar, event, command } = + await seedManaged({ + commandContent: { color: "coral" }, + }); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerEvent("Provider title", "etag-1"); + + const result = await executeProviderUpdate( + { + commands, + events, + occurrences, + resources, + connections: stubConnectionLookup(), + writer, + custody: tokenSource(), + }, + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(1); + expect(writer.patchCalls[0]!.providerManaged).toBe(true); + const stored = await events.findById(tenantId, principalId, event._id); + expect(stored?.content.color).toBe("coral"); + expect(stored?.providerVersion).toBe("etag-2" as ProviderEventVersion); + }); + + it("fails schedule edits as unsupportedCapability without patching", async () => { + const movedSchedule = { + ...schedule, + start: "2026-07-14T10:00:00-06:00" as DateTime, + end: "2026-07-14T11:00:00-06:00" as DateTime, + }; + const { calendar, event, command } = await seedManaged({ + commandSchedule: movedSchedule, + }); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerEvent("Provider title", "etag-1"); + + const result = await executeProviderUpdate( + { + commands, + events, + occurrences, + resources, + connections: stubConnectionLookup(), + writer, + custody: tokenSource(), + }, + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("failed"); + expect( + result.outcome.state === "failed" && result.outcome.failureReason, + ).toBe("unsupportedCapability"); + expect(writer.patchCalls).toHaveLength(0); + }); + + it("clears customizations when the title matches the provider again", async () => { + const { tenantId, principalId, calendar, event, command } = + await seedManaged({ + commandTitle: "Provider title", + eventCustomizations: { title: "Old overlay" }, + }); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerEvent("Provider title", "etag-1"); + + const result = await executeProviderUpdate( + { + commands, + events, + occurrences, + resources, + connections: stubConnectionLookup(), + writer, + custody: tokenSource(), + }, + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(0); + const stored = await events.findById(tenantId, principalId, event._id); + expect(stored?.customizations).toBeNull(); + }); +}); diff --git a/packages/sync/src/domain/provider-command.update.db.test.ts b/packages/sync/src/domain/provider-command.update.db.test.ts new file mode 100644 index 0000000000..7d2c27ce89 --- /dev/null +++ b/packages/sync/src/domain/provider-command.update.db.test.ts @@ -0,0 +1,430 @@ +import { faker } from "@faker-js/faker"; +import { type DateTime, type TimeZone } from "@core/types/domain-primitives"; +import { type SyncCommandInput } from "@core/types/sync/command.contracts"; +import { type ProviderEventVersion } from "@core/types/sync/event.contracts"; +import { type ProviderEventId } from "@core/types/sync/identity.contracts"; +import { + bindCommandRepos, + COMMAND_NOW, + FakeProviderEventWriter, + failingTokenSource, + newCommandIds, + providerMutationDeps, + RevokedAuthAdapter, + seedCommandCalendar, + seedLinkedEvent, + storeCommandCredential, + stubConnectionLookup, + TEST_CREDENTIAL_ENCRYPTION_KEY, + tokenSource, +} from "@sync/__tests__/helpers/command-scenario"; +import { setupSyncStorage } from "@sync/__tests__/helpers/storage"; +import { CredentialCustody } from "@sync/credentials/credential-custody.service"; +import { executeProviderUpdate } from "@sync/domain/provider-command.update"; +import { ProviderAuthError } from "@sync/providers/provider-auth.port"; +import { type ProviderEvent } from "@sync/providers/provider-event.port"; +import { ProviderWriteError } from "@sync/providers/provider-event-writer.port"; +import { SYNC_COLLECTIONS } from "@sync/storage/collections"; +import { type CommandRepository } from "@sync/storage/repositories/command.repository"; +import { type CredentialRepository } from "@sync/storage/repositories/credential.repository"; +import { type DeletionMarkerRepository } from "@sync/storage/repositories/deletion-marker.repository"; +import { type EventRepository } from "@sync/storage/repositories/event.repository"; +import { type EventOccurrenceRepository } from "@sync/storage/repositories/event-occurrence.repository"; +import { type ProviderCalendarRepository } from "@sync/storage/repositories/provider-calendar.repository"; +import { type SyncResourceRepository } from "@sync/storage/repositories/sync-resource.repository"; +import { type SyncMongoService } from "@sync/storage/sync-mongo.service"; +import { beforeEach, describe, expect, it } from "bun:test"; + +const storage = setupSyncStorage(import.meta.url); +const repos = bindCommandRepos(storage); +const _objectId = () => faker.database.mongodbObjectId(); +const now = COMMAND_NOW; + +let mongo: SyncMongoService; +let commands: CommandRepository; +let events: EventRepository; +let occurrences: EventOccurrenceRepository; +let resources: SyncResourceRepository; +let calendars: ProviderCalendarRepository; +let _markers: DeletionMarkerRepository; +let credentials: CredentialRepository; + +beforeEach(() => { + mongo = repos.mongo; + commands = repos.commands; + events = repos.events; + occurrences = repos.occurrences; + resources = repos.resources; + calendars = repos.calendars; + _markers = repos.markers; + credentials = repos.credentials; +}); + +describe("executeProviderUpdate", () => { + const schedule = { + kind: "timed" as const, + start: "2026-07-14T09:00:00-06:00" as DateTime, + end: "2026-07-14T10:00:00-06:00" as DateTime, + timeZone: "America/Denver" as TimeZone, + }; + const content = (title: string) => ({ + title, + description: "", + location: null, + organizer: null, + attendees: [], + conference: null, + }); + const providerEvent = (title: string, version: string): ProviderEvent => ({ + kind: "event", + providerEventId: "g-evt-1" as ProviderEventId, + providerVersion: version, + providerUpdatedAt: null, + content: content(title), + schedule, + busy: true, + recurrence: { kind: "single" }, + }); + + // Seed a provider-linked event plus an update command that renames it to + // "New". The provider currently holds "Old" at etag-1. + const seed = async () => { + const ids = newCommandIds(); + const calendar = await seedCommandCalendar(calendars, ids); + const event = await seedLinkedEvent(events, { + ids, + calendarId: calendar._id, + content: content("Old"), + schedule, + recurrence: { kind: "single" }, + now: now(), + }); + const { record: command } = await commands.submit({ + tenantId: ids.tenantId, + principalId: ids.principalId, + idempotencyKey: ids.idempotencyKey, + eventId: event._id, + input: { + kind: "update", + invitation: "all", + content: content("New"), + schedule, + recurrence: { kind: "preserve" }, + scope: "all", + } as unknown as SyncCommandInput, + expectedVersion: "etag-1" as never, + }); + return { + tenantId: ids.tenantId, + principalId: ids.principalId, + calendar, + event, + command, + }; + }; + + it("patches the provider and commits the new version and content", async () => { + const { tenantId, principalId, calendar, event, command } = await seed(); + const writer = new FakeProviderEventWriter(); + // The provider still holds the old content, so this is a real edit. + writer.fetched = providerEvent("Old", "etag-1"); + + const result = await executeProviderUpdate( + { + commands, + events, + occurrences, + resources, + connections: stubConnectionLookup(), + writer, + custody: tokenSource(), + }, + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(1); + expect(writer.patchCalls[0]!.expectedVersion).toBe("etag-1"); + expect(writer.patchCalls[0]!.invitation).toBe("all"); + const stored = await events.findById(tenantId, principalId, event._id); + expect(stored?.content.title).toBe("New"); + expect(stored?.providerVersion).toBe("etag-2" as ProviderEventVersion); + + // The occurrence projection is rebuilt with the edited title. + const occ = await mongo.db + .collection(SYNC_COLLECTIONS.eventOccurrences) + .find({ eventId: event._id }) + .toArray(); + expect(occ).toHaveLength(1); + expect(occ[0]?.["title"]).toBe("New"); + }); + + it("confirms without re-patching when the edit already landed (replay)", async () => { + const { tenantId, principalId, calendar, event, command } = await seed(); + const writer = new FakeProviderEventWriter(); + // The provider already holds this command's intended content at a new + // version — a prior attempt landed before the crash. + writer.fetched = providerEvent("New", "etag-2"); + + const result = await executeProviderUpdate( + { + commands, + events, + occurrences, + resources, + connections: stubConnectionLookup(), + writer, + custody: tokenSource(), + }, + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + // No second write — the replay is recognized from the fetch. + expect(writer.patchCalls).toHaveLength(0); + const stored = await events.findById(tenantId, principalId, event._id); + expect(stored?.providerVersion).toBe("etag-2" as ProviderEventVersion); + }); + + it("recognizes a replay even when read-reflected fields drifted", async () => { + const { calendar, event, command } = await seed(); + const writer = new FakeProviderEventWriter(); + // The written fields (title/description/location/schedule) match this + // command's edit, but an attendee RSVP'd after our patch landed — a field + // the patch never writes. This must still count as a replay, not a false + // conflict on an edit that already succeeded. + writer.fetched = { + kind: "event", + providerEventId: "g-evt-1" as ProviderEventId, + providerVersion: "etag-2", + providerUpdatedAt: null, + content: { + title: "New", + description: "", + location: null, + organizer: null, + attendees: [ + { + email: "guest@example.com", + displayName: null, + responseStatus: "accepted", + }, + ], + conference: null, + }, + schedule, + busy: true, + recurrence: { kind: "single" }, + }; + + const result = await executeProviderUpdate( + { + commands, + events, + occurrences, + resources, + connections: stubConnectionLookup(), + writer, + custody: tokenSource(), + }, + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(0); + }); + + it("patches against the fresh version when only the provider's version key drifted", async () => { + const { tenantId, principalId, calendar, event, command } = await seed(); + const writer = new FakeProviderEventWriter(); + // The provider rotated the version on its own (Exchange rewrites a change + // key seconds after a create) but still holds exactly what Compass stored, + // so nobody edited it elsewhere and the stale etag-1 must not block the + // edit. + writer.fetched = providerEvent("Old", "etag-1-rotated"); + + const result = await executeProviderUpdate( + { + commands, + events, + occurrences, + resources, + connections: stubConnectionLookup(), + writer, + custody: tokenSource(), + }, + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(1); + expect(writer.patchCalls[0]!.expectedVersion).toBe("etag-1-rotated"); + const stored = await events.findById(tenantId, principalId, event._id); + expect(stored?.content.title).toBe("New"); + expect(stored?.providerVersion).toBe("etag-2" as ProviderEventVersion); + }); + + it("fails with a conflict on a genuine concurrent external edit", async () => { + const { calendar, event, command } = await seed(); + const writer = new FakeProviderEventWriter(); + // The provider was edited externally (different content, and the + // conditional patch is rejected). + writer.fetched = providerEvent("Someone else's edit", "etag-9"); + writer.patchError = new ProviderWriteError("versionConflict", "stale"); + + const result = await executeProviderUpdate( + { + commands, + events, + occurrences, + resources, + connections: stubConnectionLookup(), + writer, + custody: tokenSource(), + }, + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("failed"); + expect( + result.outcome.state === "failed" && result.outcome.failureReason, + ).toBe("versionConflict"); + // The drifted version is NOT adopted when the content changed too: the + // patch stays conditioned on the stale version so it cannot overwrite + // the external edit. + expect(writer.patchCalls[0]!.expectedVersion).toBe("etag-1"); + }); + + it("fails when the provider event no longer exists", async () => { + const { calendar, event, command } = await seed(); + const writer = new FakeProviderEventWriter(); + writer.fetched = null; + + const result = await executeProviderUpdate( + { + commands, + events, + occurrences, + resources, + connections: stubConnectionLookup(), + writer, + custody: tokenSource(), + }, + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("failed"); + expect( + result.outcome.state === "failed" && result.outcome.failureReason, + ).toBe("permanentProviderError"); + expect(writer.patchCalls).toHaveLength(0); + }); + + it("leaves the command pending on a transient patch failure", async () => { + const { calendar, event, command } = await seed(); + const writer = new FakeProviderEventWriter(); + writer.fetched = providerEvent("Old", "etag-1"); + writer.patchError = new ProviderWriteError("transient", "blip"); + + const result = await executeProviderUpdate( + { + commands, + events, + occurrences, + resources, + connections: stubConnectionLookup(), + writer, + custody: tokenSource(), + }, + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("pending"); + }); + + it("fails without touching the provider when the credential is revoked", async () => { + const { calendar, event, command } = await seed(); + const writer = new FakeProviderEventWriter(); + + const result = await executeProviderUpdate( + providerMutationDeps( + { commands, events, occurrences, resources }, + writer, + { + custody: failingTokenSource( + new ProviderAuthError("authorizationRevoked", "revoked"), + ), + }, + ), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("failed"); + expect(writer.fetchCalls).toHaveLength(0); + expect(writer.patchCalls).toHaveLength(0); + }); + + it("discards the credential when a writer 401 classifies as authorizationRevoked", async () => { + const { calendar, event, command } = await seed(); + await storeCommandCredential(credentials, calendar.connectionId, { + token: "still-cached", + expiresAt: new Date("2099-01-01T00:00:00Z"), + }); + const custody = new CredentialCustody( + credentials, + () => new RevokedAuthAdapter(), + undefined, + undefined, + TEST_CREDENTIAL_ENCRYPTION_KEY, + ); + const writer = new FakeProviderEventWriter(); + writer.fetchError = new ProviderWriteError( + "authorizationRevoked", + "token rejected", + ); + + const result = await executeProviderUpdate( + providerMutationDeps( + { commands, events, occurrences, resources }, + writer, + { custody }, + ), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("failed"); + expect( + result.outcome.state === "failed" && result.outcome.failureReason, + ).toBe("authorizationRevoked"); + expect( + await credentials.findByConnection(calendar.connectionId), + ).toBeNull(); + }); +}); diff --git a/packages/sync/src/domain/provider-command.update.ts b/packages/sync/src/domain/provider-command.update.ts new file mode 100644 index 0000000000..ebfe3ae734 --- /dev/null +++ b/packages/sync/src/domain/provider-command.update.ts @@ -0,0 +1,446 @@ +import { type EventSchedule } from "@core/types/event.contracts"; +import { type Attendee } from "@core/types/event-attendance.contracts"; +import { + type ProviderEventVersion, + type SyncEventContent, +} from "@core/types/sync/event.contracts"; +import { + type ConnectionId, + type ProviderEventId, +} from "@core/types/sync/identity.contracts"; +import { + mergeAttendees, + mergeUpdateContent, + omitNullColor, + resolveUpdateContent, + resolveUpdateSchedule, +} from "@sync/domain/merge-update-content"; +import { type ProviderMutationDeps } from "@sync/domain/provider-command.deps"; +import { + attendeesMatchIntent, + deepEqual, + intendedSeriesRecurrence, + matchesIntendedEdit, + patchExpectedVersion, + storedSeriesRecurrence, +} from "@sync/domain/provider-command.intent-match"; +import { + failCommand, + organizerGuardFailure, +} from "@sync/domain/provider-command.internal"; +import { + resolveAccessToken, + runProviderWrite, +} from "@sync/domain/provider-write-ladder"; +import { reprojectOccurrences } from "@sync/domain/reproject"; +import { type ProviderEvent } from "@sync/providers/provider-event.port"; +import { type ProviderWriteRecurrence } from "@sync/providers/provider-event-writer.port"; +import { type CommandRecord } from "@sync/storage/contracts/command.contracts"; +import { type EventRecord } from "@sync/storage/contracts/event.contracts"; +import { type ProviderCalendarRecord } from "@sync/storage/contracts/provider-calendar.contracts"; + +export async function executeProviderUpdate( + deps: ProviderMutationDeps, + command: CommandRecord, + event: EventRecord, + calendar: ProviderCalendarRecord, + now: () => Date, +): Promise { + if (command.input.kind !== "update") { + throw new Error("executeProviderUpdate requires an update command"); + } + if (!event.connectionId || !event.providerEventId) { + throw new Error("executeProviderUpdate requires a linked event"); + } + const { input } = command; + const providerEventId = event.providerEventId; + const connectionId = event.connectionId; + + // A guest-list replace is only supported for the organizer; a non-organizer + // replace fails typed before any provider call. + if (input.attendeesEdit === "replace") { + const guardFailure = await organizerGuardFailure( + deps, + command, + event, + connectionId, + ); + if (guardFailure) return guardFailure; + } + + const tokenResult = await resolveAccessToken(deps.custody, connectionId); + if (!tokenResult.ok) { + if (tokenResult.stop.kind === "pending") return command; + return failCommand(deps, command, tokenResult.stop.reason, connectionId); + } + const { accessToken } = tokenResult; + + const location = { + accessToken, + calendarId: calendar.providerCalendarId, + providerEventId, + }; + + // Fetch current provider state to detect a replay (our edit already landed) + // and to learn the version to commit. + // A cancellation read means the event no longer exists as a content event — + // there is nothing to update. + const fetchResult = await runProviderWrite(() => + deps.writer.fetchEvent(location), + ); + if (!fetchResult.ok) { + if (fetchResult.stop.kind === "pending") return command; + return failCommand(deps, command, fetchResult.stop.reason, connectionId); + } + const current = + fetchResult.value?.kind === "event" ? fetchResult.value : null; + if (!current) { + return failCommand(deps, command, "permanentProviderError", connectionId); + } + + // Merge so a title/description edit cannot wipe provider-sourced attendees. + // Omitting content or schedule keeps the freshly fetched provider values so + // a content-only booking patch cannot restore a host's concurrent move. + let content = resolveUpdateContent( + event.content, + input.content, + current.content, + ); + const schedule = resolveUpdateSchedule(input.schedule, current.schedule); + // A "replace" merges the intended membership against the FRESHLY FETCHED + // provider list (current.content), never sync's stored record: the Google + // patch replaces the whole attendees array, and merging against a stale + // stored copy would clobber a concurrent RSVP made between syncs. The + // merged list also lands on the local record at commit, so reads reflect + // the edit before the next provider round-trip. + const intendedAttendees = + input.attendeesEdit === "replace" && input.content + ? mergeAttendees(input.content.attendees, current.content.attendees) + : undefined; + if (intendedAttendees) { + content = { ...content, attendees: intendedAttendees }; + } + // Almost always "single" (event.recurrence.kind is single here, so + // "preserve" resolves to single via intendedSeriesRecurrence's own + // fallback) — except a single→series conversion, which writes real rules. + const intendedRecurrence = intendedSeriesRecurrence(input.recurrence, event); + + if (current.providerManaged) { + return executeProviderManagedUpdate( + deps, + command, + event, + current, + content, + input, + intendedRecurrence, + intendedAttendees, + location, + connectionId, + now, + ); + } + + // Replay: the provider already holds this edit, so confirm at its version + // rather than writing again. + if ( + matchesIntendedEdit( + current, + content, + schedule, + intendedRecurrence, + intendedAttendees, + ) + ) { + return commitProviderUpdate( + deps, + command, + event, + content, + current.providerVersion, + now, + ); + } + + const patchResult = await runProviderWrite(() => + deps.writer.patchEvent({ + ...location, + expectedVersion: patchExpectedVersion( + command, + current, + event, + intendedAttendees !== undefined, + ), + content, + schedule, + recurrence: intendedRecurrence, + invitation: input.invitation, + ...(intendedAttendees ? { attendees: intendedAttendees } : {}), + }), + ); + if (!patchResult.ok) { + if (patchResult.stop.kind === "pending") return command; + return failCommand(deps, command, patchResult.stop.reason, connectionId); + } + const result = patchResult.value; + + return commitProviderUpdate( + deps, + command, + event, + content, + result.providerVersion, + now, + ); +} + +// Commit an updated provider event: write the new content/version to the +// canonical record (owner-scoped, non-upsert so a concurrent delete is not +// resurrected), then confirm. A miss means the local event vanished mid-flight, +// so leave the command pending to re-evaluate rather than confirm a gone event. +// recurrence is recomputed (not left as event.recurrence) so a single→series +// conversion actually persists its new rules locally, not just at the provider. +async function commitProviderUpdate( + deps: ProviderMutationDeps, + command: CommandRecord, + event: EventRecord, + content: SyncEventContent, + providerVersion: string, + now: () => Date, + commitOptions?: { + schedule?: EventSchedule; + customizations?: EventRecord["customizations"]; + }, +): Promise { + if (command.input.kind !== "update") { + throw new Error("commitProviderUpdate requires an update command"); + } + const { input } = command; + const updated: EventRecord = { + ...event, + content, + schedule: commitOptions?.schedule ?? input.schedule ?? event.schedule, + recurrence: storedSeriesRecurrence(input.recurrence, event), + providerVersion: providerVersion as ProviderEventVersion, + providerUpdatedAt: null, + deliveryState: "confirmed", + updatedAt: now(), + }; + if (commitOptions && "customizations" in commitOptions) { + updated.customizations = commitOptions.customizations; + } + const applied = await deps.events.replaceExisting(updated); + if (!applied) return command; + await reprojectOccurrences(deps.occurrences, updated, now); + + const confirmed = await deps.commands.updateOutcome( + command.tenantId, + command.principalId, + command._id, + { + state: "confirmed", + providerEventId: event.providerEventId as ProviderEventId, + providerVersion: providerVersion as ProviderEventVersion, + }, + command.attemptCount, + ); + return confirmed ?? command; +} + +// Provider-managed events keep syncing from the provider; Compass overlays +// title, description, and location as customizations and writes only color and +// guest-list changes the provider accepts. +async function executeProviderManagedUpdate( + deps: ProviderMutationDeps, + command: CommandRecord, + event: EventRecord, + current: ProviderEvent, + mergedContent: SyncEventContent, + input: Extract, + intendedRecurrence: ProviderWriteRecurrence, + intendedAttendees: readonly Attendee[] | undefined, + location: { + accessToken: string; + calendarId: string; + providerEventId: string; + }, + connectionId: ConnectionId, + now: () => Date, +): Promise { + if ( + !deepEqual( + resolveUpdateSchedule(input.schedule, current.schedule), + current.schedule, + ) || + intendedRecurrence.kind !== "single" + ) { + return failCommand(deps, command, "unsupportedCapability", connectionId); + } + + const customizations = computeEventCustomizations( + current.content, + mergedContent, + ); + const storedContent = managedStoredContent( + current.content, + mergedContent, + intendedAttendees, + ); + const commitOptions = { + schedule: current.schedule, + customizations, + }; + + if ( + matchesManagedIntendedEdit( + current, + event, + mergedContent, + customizations, + intendedAttendees, + ) + ) { + return commitProviderUpdate( + deps, + command, + event, + storedContent, + current.providerVersion, + now, + commitOptions, + ); + } + + if (!managedProviderSideChange(current, mergedContent, intendedAttendees)) { + return commitProviderUpdate( + deps, + command, + event, + storedContent, + current.providerVersion, + now, + commitOptions, + ); + } + + const patchResult = await runProviderWrite(() => + deps.writer.patchEvent({ + ...location, + expectedVersion: patchExpectedVersion( + command, + current, + event, + intendedAttendees !== undefined, + ), + providerManaged: true, + content: mergedContent, + schedule: current.schedule, + recurrence: { kind: "single" }, + invitation: input.invitation, + ...(intendedAttendees ? { attendees: intendedAttendees } : {}), + }), + ); + if (!patchResult.ok) { + if (patchResult.stop.kind === "pending") return command; + return failCommand(deps, command, patchResult.stop.reason, connectionId); + } + + return commitProviderUpdate( + deps, + command, + event, + storedContent, + patchResult.value.providerVersion, + now, + commitOptions, + ); +} + +function computeEventCustomizations( + providerContent: SyncEventContent, + intended: SyncEventContent, +): EventRecord["customizations"] { + const customizations: { + title?: string; + description?: string; + location?: string | null; + } = {}; + if (intended.title !== providerContent.title) { + customizations.title = intended.title; + } + if (intended.description !== providerContent.description) { + customizations.description = intended.description; + } + if (intended.location !== providerContent.location) { + customizations.location = intended.location; + } + return Object.keys(customizations).length === 0 ? null : customizations; +} + +function managedStoredContent( + providerContent: SyncEventContent, + intended: SyncEventContent, + intendedAttendees: readonly Attendee[] | undefined, +): SyncEventContent { + let stored = omitNullColor( + mergeUpdateContent(providerContent, { + ...providerContent, + color: intended.color, + }), + ); + if (intendedAttendees) { + stored = { ...stored, attendees: intendedAttendees }; + } + return stored; +} + +function customizationsEqual( + left: EventRecord["customizations"], + right: EventRecord["customizations"], +): boolean { + const normalize = (value: EventRecord["customizations"]) => + value === undefined || value === null ? null : value; + return deepEqual(normalize(left), normalize(right)); +} + +function managedProviderSideChange( + current: ProviderEvent, + content: SyncEventContent, + intendedAttendees: readonly Attendee[] | undefined, +): boolean { + const intendedColor = content.color === null ? undefined : content.color; + const currentColor = + current.content.color === null ? undefined : current.content.color; + if (intendedColor !== currentColor) return true; + if (intendedAttendees === undefined) return false; + return !attendeesMatchIntent(current.content.attendees, intendedAttendees); +} + +function matchesManagedIntendedEdit( + current: ProviderEvent, + event: EventRecord, + mergedContent: SyncEventContent, + customizations: EventRecord["customizations"], + intendedAttendees: readonly Attendee[] | undefined, +): boolean { + if (!customizationsEqual(event.customizations, customizations)) return false; + return !managedProviderSideChange(current, mergedContent, intendedAttendees); +} + +// Apply a Compass-initiated scope-"all" edit to a provider-linked recurring +// series — Google's "edit all events in the series". The master is patched with +// the new content, schedule, AND recurrence rules, and its per-instance +// overrides fall away. Kept separate from executeProviderUpdate because the +// local commit is series-aware: it discards override exceptions but preserves +// cancelled tombstones (a deletion must survive an edit) and reprojects the +// master excluding their instants. +// +// Provider-managed events are single-only today (Google Gmail events), so this +// path is unreachable for them; managed customizations live in the single-event +// update path instead. +// +// Replay safety mirrors the single-event path: fetch the provider's current +// master first; if it already carries this edit (content, schedule, and rules), +// a prior attempt landed, so confirm at the current version without re-writing. +// Otherwise patch conditionally on the command's expected version, turning a +// genuine concurrent external edit into a versionConflict. diff --git a/packages/sync/src/domain/stale-command-retry.service.db.test.ts b/packages/sync/src/domain/stale-command-retry.service.db.test.ts index 14ef4bc973..4a4d3ebc68 100644 --- a/packages/sync/src/domain/stale-command-retry.service.db.test.ts +++ b/packages/sync/src/domain/stale-command-retry.service.db.test.ts @@ -22,7 +22,7 @@ import { } from "@sync/__tests__/helpers/fixtures"; import { setupSyncStorage } from "@sync/__tests__/helpers/storage"; import { submitCloudCommand } from "@sync/domain/cloud-command.service"; -import { type ProviderConnectionLookup } from "@sync/domain/provider-command.service"; +import { type ProviderConnectionLookup } from "@sync/domain/provider-command.deps"; import { retryStaleCommands, type StaleCommandRetryDeps, diff --git a/packages/sync/tsconfig.json b/packages/sync/tsconfig.json index 68dfdb752f..70e9e882ea 100644 --- a/packages/sync/tsconfig.json +++ b/packages/sync/tsconfig.json @@ -1,7 +1,12 @@ { "extends": "../../tsconfig.json", "include": ["src/**/*.ts"], - "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts", "src/**/__tests__/**"], + "exclude": [ + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/__tests__/**", + "src/domain/provider-command.*.db.test.ts" + ], "compilerOptions": { "composite": false, "declarationMap": false, diff --git a/tsconfig.tests.json b/tsconfig.tests.json index 8cc5bd443a..9979528617 100644 --- a/tsconfig.tests.json +++ b/tsconfig.tests.json @@ -9,7 +9,7 @@ "packages/scripts/src/**/*.d.ts", "**/*.json" ], - "exclude": [], + "exclude": ["packages/sync/src/domain/provider-command.*.db.test.ts"], "compilerOptions": { "composite": true, "declaration": true,