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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/core/src/types/sync/command.contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,25 @@ describe("Sync command contracts", () => {
);
});

it("defaults an update's invitation to none when absent", () => {
const parsed = SyncCommandInputSchema.safeParse(updateInput());
expect(parsed.success && parsed.data.kind === "update").toBe(true);
if (parsed.success && parsed.data.kind === "update") {
expect(parsed.data.invitation).toBe("none");
}
});

it("accepts an update carrying an explicit invitation", () => {
const parsed = SyncCommandInputSchema.safeParse({
...updateInput(),
invitation: "all",
});
expect(parsed.success && parsed.data.kind === "update").toBe(true);
if (parsed.success && parsed.data.kind === "update") {
expect(parsed.data.invitation).toBe("all");
}
});

it("accepts a move input", () => {
expect(SyncCommandInputSchema.safeParse(moveInput()).success).toBe(true);
});
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/types/sync/command.contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,11 @@ const CreateCommandInputSchema = z.strictObject({

// update never changes the owning calendar — that is exclusively the "move"
// operation's job, so the two stay independently retryable and idempotent.
// invitation carries the user's choice of whether to notify attendees of the
// edit, same as create; default is to notify no one.
const UpdateCommandInputSchema = z.strictObject({
kind: z.literal("update"),
invitation: InvitationIntentSchema,
content: SyncEventContentSchema,
schedule: EventScheduleSchema,
recurrence: RecurrenceEditSchema,
Expand Down
92 changes: 88 additions & 4 deletions packages/sync/src/domain/cloud-command.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import {
type TenantId,
} from "@core/types/sync/identity.contracts";
import { submitCloudCommand } from "@sync/domain/cloud-command.service";
import { type ProviderEvent } from "@sync/providers/provider-event.port";
import {
type ProviderCreateInput,
type ProviderEventWriter,
type ProviderPatchInput,
type ProviderWriteResult,
} from "@sync/providers/provider-event-writer.port";
import { type CommandSubmit } from "@sync/storage/contracts/command.contracts";
Expand All @@ -26,18 +28,44 @@ const objectId = () => faker.database.mongodbObjectId();
class FakeWriter implements ProviderEventWriter {
readonly provider = "google" as const;
calls: ProviderCreateInput[] = [];
patchCalls: ProviderPatchInput[] = [];
// A stub current provider event for the update path's replay-detection fetch;
// its content differs from any command's intent so an update always patches.
fetched: ProviderEvent | null = {
kind: "event",
providerEventId: "g-evt-1",
providerVersion: "etag-1",
providerUpdatedAt: null,
content: {
title: "Provider copy",
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",
},
busy: true,
recurrence: { kind: "single" },
};
async createEvent(input: ProviderCreateInput): Promise<ProviderWriteResult> {
this.calls.push(input);
return { providerEventId: "g-evt-1", providerVersion: "etag-1" };
}
patchEvent(): Promise<ProviderWriteResult> {
throw new Error("unused");
async patchEvent(input: ProviderPatchInput): Promise<ProviderWriteResult> {
this.patchCalls.push(input);
return { providerEventId: "g-evt-1", providerVersion: "etag-2" };
}
deleteEvent(): Promise<void> {
throw new Error("unused");
}
fetchEvent(): Promise<null> {
throw new Error("unused");
async fetchEvent(): Promise<ProviderEvent | null> {
return this.fetched;
}
}

Expand Down Expand Up @@ -311,4 +339,60 @@ describe("submitCloudCommand provider dispatch", () => {
await events.findById(tenantId, principalId, eventId),
).not.toBeNull();
});

it("routes a provider-linked update to the provider executor when active", async () => {
const tenantId = objectId() as TenantId;
const principalId = objectId() as PrincipalId;
const calendar = await seedProviderCalendar(tenantId, principalId);
const eventId = objectId() as EventId;
await seedEvent(tenantId, principalId, eventId, {
calendarId: calendar._id,
connectionId: calendar.connectionId as never,
providerEventId: "g-evt-1" as never,
providerVersion: "etag-1" as never,
deliveryState: "confirmed",
});
const writer = new FakeWriter();

const command = await submitCloudCommand(
{
commands,
events,
calendars,
execution: "active",
provider: provider(writer),
},
{
tenantId,
principalId,
idempotencyKey: `idem-${objectId()}` as IdempotencyKey,
eventId,
input: {
kind: "update",
invitation: "none",
content: {
title: "Renamed",
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: "preserve" },
scope: "all",
} as unknown as SyncCommandInput,
expectedVersion: "etag-1" as never,
},
now,
);

expect(command.outcome.state).toBe("confirmed");
expect(writer.patchCalls).toHaveLength(1);
});
});
46 changes: 37 additions & 9 deletions packages/sync/src/domain/cloud-command.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { type SyncEventRecurrence } from "@core/types/sync/event.contracts";
import { type ProviderCalendarId } from "@core/types/sync/identity.contracts";
import { type SyncExecutionMode } from "@sync/config/sync.config";
import { type CredentialCustody } from "@sync/credentials/credential-custody.service";
import { executeProviderCreate } from "@sync/domain/provider-command.service";
import {
executeProviderCreate,
executeProviderUpdate,
} from "@sync/domain/provider-command.service";
import { type ProviderEventWriter } from "@sync/providers/provider-event-writer.port";
import {
type CommandRecord,
Expand Down Expand Up @@ -125,17 +128,42 @@ async function applyCloudMutation(
// update: the target must exist; a missing event can't be updated, so leave
// the command pending rather than confirming a no-op.
if (!existing) return command;
if (existing.connectionId !== null) return command;
// A recurring series needs scope handling (later slice), and converting a
// single event into a series is itself a series edit — defer both. Gating on
// the command's intent (not the event's post-write recurrence) keeps a retry
// converging: the applied update never changes recurrence.kind here.
if (existing.recurrence.kind !== "single") return command;
// Converting a single event into a series is a series-scope edit — defer it.
// Gating on the command's intent (not the event's post-write recurrence) keeps
// a retry converging: applyCloudUpdate never changes recurrence.kind here, so
// the guards above still pass on the re-read.
if (command.input.recurrence.kind === "series") return command;

// Conditional replace (no upsert): if a concurrent delete removed the event
// between the read and here, the write is a no-op and the command stays
// pending rather than resurrecting the deleted event.
// A provider-linked event goes to the provider when provider work is enabled;
// otherwise it stays pending for a later execution.
if (existing.connectionId !== null) {
if (deps.execution === "active" && deps.provider) {
const calendar = await deps.calendars.findById(
command.tenantId,
command.principalId,
existing.calendarId as ProviderCalendarId,
);
if (calendar) {
return executeProviderUpdate(
{
commands: deps.commands,
events: deps.events,
writer: deps.provider.writer,
custody: deps.provider.custody,
},
command,
existing,
calendar,
now,
);
}
}
return command;
}

// Cloud single event: conditional replace (no upsert), so a concurrent delete
// is not resurrected — a miss leaves the command pending.
const applied = await deps.events.replaceExisting(
applyCloudUpdate(existing, command, now()),
);
Expand Down
Loading