Skip to content
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Internal documentation for engineers and agents working in the Compass repo.

- [Repo Architecture](./architecture/repo-architecture.md)
- [Event Domain Model](./architecture/event-domain-model.md)
- [Multi-account Sync](./architecture/multi-account-sync.md)
- [Glossary](./architecture/glossary.md)

## Development And Operations
Expand Down
61 changes: 61 additions & 0 deletions docs/architecture/multi-account-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Multi-account sync invariants

These rules keep Compass's per-account Google sync state honest after
email/password signup plus "Add account", reconnect, and missed SSE.

Zustand remains the store for user metadata. The failure mode was refresh
discipline, not the store; migrating metadata to react-query would touch
dozens of consumers (auth, sidebar, settings, SSE handlers, and the
`__COMPASS_E2E_STORE__` Playwright bridge) to fix a problem four files own.
Calendars live in react-query and connections in zustand; refetching both
unconditionally on the same signals keeps them from diverging.

## 1. Push on every transition

`refreshConnectionState` is the only writer of derived connection state. On
change it persists the new state and appends a `kind: "connection"`
invalidation. The backend SSE bridge fans those invalidations out as
`calendarsChanged`.

Any new job kind or route that changes state-derivation evidence (bootstrap
flags, cursors, credentials, calendar-list discovery, durable read failures)
must end by calling `refreshConnectionState` with the invalidations
repository. Do not grow a second push channel. Delete the dormant
`importProgress` invalidation kind and caller-less
`sseServer.publishUserMetadata` in a follow-up cleanup rather than adding
another path.

At dispatch time the completing job is still claimed, so derivation often
lands on `catchingUp` first. That is still a change from `importing`, so the
invalidation fires, the client refetches, and the read-path refresh after the
job settles lands `healthy`.

## 2. Pull reconciliation, unconditionally cheap

Force-refresh metadata on every SSE signal, on stream open/reopen, and on
tab focus — regardless of connection state — plus a 20s poll while any
**single** connection is `connecting` / `importing` / `catchingUp`.

The sync `GET /connections` read path re-derives state, so every metadata
pull is also a server-side reconciliation. The UI self-heals even with SSE
fully dead. Concurrent `force` refreshes chain onto one trailing fetch;
an epoch counter drops stale writes.

Do not gate metadata reconciliation on HEALTHY/ATTENTION. That gate is only
for the provider Refresh enqueue.

## 3. Per-account attribution

User-visible status hangs off one connection, never the precedence-collapsed
aggregate. A stuck account must not pin an unattributed banner (or disable
reconciliation) for all accounts.

- The local Compass calendar is its own sidebar section (the signed-in
user's email) once any Google account exists. It stays visible and
toggleable. LCV1/LCV2 still exclude it as a create target.
- A connected-but-still-importing account keeps its section header with
zero calendars so "Adding your calendar…" attributes to that account.
- Day view columns are active+visible calendars, matching Week.

Follow-up candidate: `SidebarStatusBar` names the account when more than one
connection exists.
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,33 @@ describe("handleGoogleAuth", () => {
expect(googleAuthService.googleSignup).not.toHaveBeenCalled();
expect(adoptCalls).toHaveLength(0);
});

it("does not create a user when an existing session would otherwise SIGNUP", async () => {
const success: GoogleSignInSuccess = {
providerUser: makeProviderUser(),
oAuthTokens: makeOAuthTokens(),
createdNewRecipeUser: true,
recipeUserId: faker.database.mongodbObjectId(),
loginMethodsLength: 1,
};

mockDetermineGoogleAuthMode.mockResolvedValue(
makeDecision({ authMode: "SIGNUP" }),
);

await expect(
googleAuthService.handleGoogleAuth(success, {
hasExistingSession: true,
}),
).rejects.toMatchObject({
result:
"You're already signed in — use Settings → Add account to connect this Google account.",
code: "GOOGLE_SIGNIN_WHILE_AUTHENTICATED",
});

expect(googleAuthService.googleSignup).not.toHaveBeenCalled();
expect(adoptCalls).toHaveLength(0);
});
});

describe("SIGNIN path", () => {
Expand Down
11 changes: 10 additions & 1 deletion packages/backend/src/auth/services/google/google.auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,10 @@ async function adoptConnection(
);
}

async function handleGoogleAuth(success: GoogleSignInSuccess): Promise<void> {
async function handleGoogleAuth(
success: GoogleSignInSuccess,
options?: { hasExistingSession?: boolean },
): Promise<void> {
const {
providerUser,
oAuthTokens,
Expand Down Expand Up @@ -237,6 +240,12 @@ async function handleGoogleAuth(success: GoogleSignInSuccess): Promise<void> {

switch (decision.authMode) {
case "SIGNUP": {
if (options?.hasExistingSession) {
throw error(
AuthError.GoogleSignInWhileAuthenticated,
"You're already signed in — use Settings → Add account to connect this Google account.",
);
}
const isNewUser = createdNewRecipeUser && loginMethodsLength === 1;
if (!isNewUser) {
// Edge case: no Compass user found but SuperTokens says not new
Expand Down
8 changes: 8 additions & 0 deletions packages/backend/src/common/errors/auth/auth.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ interface AuthErrors {
GoogleNotConfigured: ErrorMetadata;
GoogleRedirectUriMismatch: ErrorMetadata;
GoogleRefreshTokenMissing: ErrorMetadata;
GoogleSignInWhileAuthenticated: ErrorMetadata;
InadequatePermissions: ErrorMetadata;
NoGAuthAccessToken: ErrorMetadata;
SyncConnectionUnavailable: ErrorMetadata;
Expand Down Expand Up @@ -50,6 +51,13 @@ export const AuthError: AuthErrors = {
status: Status.CONFLICT,
isOperational: true,
},
GoogleSignInWhileAuthenticated: {
code: "GOOGLE_SIGNIN_WHILE_AUTHENTICATED",
description:
"You're already signed in — use Settings → Add account to connect this Google account.",
status: Status.CONFLICT,
isOperational: true,
},
InadequatePermissions: {
description: "You don't have permission to do that",
status: Status.FORBIDDEN,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ export async function handleGoogleSignInUp(
success,
);

await googleAuthService.handleGoogleAuth(remapped.success);
await googleAuthService.handleGoogleAuth(remapped.success, {
hasExistingSession: Boolean(input.session),
});

return remapped.response;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as corsLib from "cors";
import { ObjectId } from "mongodb";
import superTokensNode from "supertokens-node";
import Dashboard from "supertokens-node/recipe/dashboard";
import EmailPassword from "supertokens-node/recipe/emailpassword";
Expand Down Expand Up @@ -464,6 +463,42 @@ describe("supertokens.middleware", () => {
});
expect(googleAuthService.handleGoogleAuth).toHaveBeenCalledWith(
successPayload,
{ hasExistingSession: false },
);
});

it("tells handleGoogleAuth when ThirdParty signInUpPOST already has a session", async () => {
const responsePayload = { status: "OK" };
const successPayload = { providerUser: { id: "u1" } };
const existingSession = { getUserId: () => "existing-user" };

(createGoogleSignInSuccess as Mock).mockReturnValue(successPayload);

initSupertokens();

const thirdPartyConfig = getFirstCallArg<{
override: {
apis: (originalImplementation: {
signInUpPOST?: (input: unknown) => Promise<unknown>;
}) => {
signInUpPOST: (input: unknown) => Promise<unknown>;
};
};
}>(ThirdParty.init);

const originalImplementation = {
signInUpPOST: mock().mockResolvedValue(responsePayload),
};

const overridden = thirdPartyConfig.override.apis(originalImplementation);
const input = { session: existingSession, some: "input" };

await overridden.signInUpPOST(input);

expect(originalImplementation.signInUpPOST).toHaveBeenCalledWith(input);
expect(googleAuthService.handleGoogleAuth).toHaveBeenCalledWith(
successPayload,
{ hasExistingSession: true },
);
});

Expand Down Expand Up @@ -663,6 +698,7 @@ describe("supertokens.middleware", () => {
expect(createGoogleSignInSuccess).toHaveBeenCalledTimes(2);
expect(googleAuthService.handleGoogleAuth).toHaveBeenCalledWith(
expect.objectContaining({ recipeUserId: "compass-user-id" }),
{ hasExistingSession: false },
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,6 @@ describe("auditConnectionIdentity (db)", () => {
capabilities: ["readEvents", "readBusy", "writeEvents"],
state: "healthy",
stateReason: null,
lastSyncedAt: null,
lastHealthyAt: null,
});
};

Expand Down
1 change: 1 addition & 0 deletions packages/sync/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,7 @@ function buildSchedulers(
resources,
calendars: repos.calendars,
connections: repos.connections,
credentials: repos.credentials,
discovery: new GoogleCalendarAdapter(),
commands: repos.commands,
jobs,
Expand Down
16 changes: 14 additions & 2 deletions packages/sync/src/domain/busy-availability.service.db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,21 @@ describe("computeBusyAvailability", () => {
capabilities: ["readEvents"],
state,
stateReason: null,
lastSyncedAt,
lastHealthyAt: lastSyncedAt,
});
if (lastSyncedAt !== null) {
await connections.updateDerivedState(
tenantId,
principalId,
connection._id,
{
state,
stateReason: null,
lastSyncedAt,
lastHealthyAt: lastSyncedAt,
},
NOW,
);
}
return connection._id;
};

Expand Down
54 changes: 52 additions & 2 deletions packages/sync/src/domain/connection-refresh.service.db.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { seedProviderCalendar } from "@sync/__tests__/helpers/fixtures";
import { setupSyncStorage } from "@sync/__tests__/helpers/storage";
import { refreshPrincipalCalendars } from "@sync/domain/connection-refresh.service";
import { SYNC_COLLECTIONS } from "@sync/storage/collections";
import { JOB_PRIORITY } from "@sync/storage/contracts/job.contracts";
import { type ProviderCalendarRecord } from "@sync/storage/contracts/provider-calendar.contracts";
import { JobRepository } from "@sync/storage/repositories/job.repository";
import { ProviderCalendarRepository } from "@sync/storage/repositories/provider-calendar.repository";
import { ProviderConnectionRepository } from "@sync/storage/repositories/provider-connection.repository";
import { SyncResourceRepository } from "@sync/storage/repositories/sync-resource.repository";

const now = () => new Date("2026-08-10T12:00:00.000Z");
Expand All @@ -17,11 +20,12 @@ describe("refreshPrincipalCalendars (db)", () => {
resources: new SyncResourceRepository(db),
jobs: new JobRepository(db),
calendars: new ProviderCalendarRepository(db),
connections: new ProviderConnectionRepository(db),
};
};

it("revives a wedged failed repair job for the connection, not just incrementalPull", async () => {
const { resources, jobs, calendars } = deps();
const { resources, jobs, calendars, connections } = deps();
const calendar: ProviderCalendarRecord =
await seedProviderCalendar(calendars);
const resource = await resources.ensure({
Expand Down Expand Up @@ -51,7 +55,7 @@ describe("refreshPrincipalCalendars (db)", () => {
await jobs.fail(claimed._id, "worker-1");

const tally = await refreshPrincipalCalendars(
{ resources, jobs },
{ resources, jobs, connections },
resource.tenantId,
resource.principalId,
now,
Expand All @@ -66,4 +70,50 @@ describe("refreshPrincipalCalendars (db)", () => {
expect(revived?.state).toBe("pending");
expect(revived?.attempt).toBe(0);
});

it("enqueues a coalesced calendarListSync per connection at user priority", async () => {
const { resources, jobs, calendars, connections } = deps();
const calendar: ProviderCalendarRecord =
await seedProviderCalendar(calendars);
await resources.ensure({
tenantId: calendar.tenantId,
principalId: calendar.principalId,
connectionId: calendar.connectionId,
resourceKind: "events",
calendarId: calendar._id,
});

await refreshPrincipalCalendars(
{ resources, jobs, connections },
calendar.tenantId,
calendar.principalId,
now,
);

const discovery = await storage
.db()
.collection(SYNC_COLLECTIONS.jobs)
.findOne({ coalescingKey: `calendarListSync:${calendar.connectionId}` });
expect(discovery).toMatchObject({
kind: "calendarListSync",
resourceId: null,
coalescingKey: `calendarListSync:${calendar.connectionId}`,
priority: JOB_PRIORITY.user,
});

await refreshPrincipalCalendars(
{ resources, jobs, connections },
calendar.tenantId,
calendar.principalId,
now,
);
expect(
await storage
.db()
.collection(SYNC_COLLECTIONS.jobs)
.countDocuments({
coalescingKey: `calendarListSync:${calendar.connectionId}`,
}),
).toBe(1);
});
});
Loading