From 41ce2840e41ec207fa4c2184bbf98def5f71e5be Mon Sep 17 00:00:00 2001
From: Kaan Karaca
Date: Sun, 19 Apr 2026 21:17:15 +0300
Subject: [PATCH 1/9] feat(calendar): partition keytar entries by accountId (M6
T1)
Multi-account groundwork for Google Calendar. The keytar service is
unchanged but each token slot is now keyed off
`-[-]` so two Google accounts on the same
machine can coexist without overwriting each other.
`storeGoogleCalendarTokens` / `getGoogleCalendarTokens` /
`hasGoogleCalendarTokens` / `clearGoogleCalendarTokens` now take an
accountId. A transitional `LEGACY_DEFAULT_ACCOUNT_ID` placeholder keeps
existing single-account callers compiling and passing tests; M6 T2 will
replace every reference with the real Google profile email returned by
userinfo.
Tests: keychain.test.ts asserts that two accounts land in distinct
slots, that clearing one leaves the other intact, and that MEMRY_DEVICE
suffixing layers cleanly on top of accountId partitioning.
Closes part of G9 (single-Google-account-only).
---
.../src/main/calendar/google/client.test.ts | 9 +-
.../src/main/calendar/google/client.ts | 15 +-
.../src/main/calendar/google/keychain.test.ts | 131 ++++++++++++++++++
.../src/main/calendar/google/keychain.ts | 66 +++++----
.../src/main/calendar/google/oauth.test.ts | 18 +--
.../desktop/src/main/calendar/google/oauth.ts | 10 +-
6 files changed, 203 insertions(+), 46 deletions(-)
create mode 100644 apps/desktop/src/main/calendar/google/keychain.test.ts
diff --git a/apps/desktop/src/main/calendar/google/client.test.ts b/apps/desktop/src/main/calendar/google/client.test.ts
index 391ee59e5..f49952588 100644
--- a/apps/desktop/src/main/calendar/google/client.test.ts
+++ b/apps/desktop/src/main/calendar/google/client.test.ts
@@ -29,7 +29,11 @@ vi.mock('../../lib/logger', () => ({
}))
import { createGoogleCalendarClient } from './client'
-import { clearGoogleCalendarTokens, storeGoogleCalendarTokens } from './keychain'
+import {
+ LEGACY_DEFAULT_ACCOUNT_ID,
+ clearGoogleCalendarTokens,
+ storeGoogleCalendarTokens
+} from './keychain'
describe('google calendar client — push channels (Task 7)', () => {
const keytarStore = new Map()
@@ -53,6 +57,7 @@ describe('google calendar client — push channels (Task 7)', () => {
})
await storeGoogleCalendarTokens({
+ accountId: LEGACY_DEFAULT_ACCOUNT_ID,
accessToken: 'seeded-access-token',
refreshToken: 'seeded-refresh-token'
})
@@ -61,7 +66,7 @@ describe('google calendar client — push channels (Task 7)', () => {
afterEach(async () => {
delete process.env.GOOGLE_CALENDAR_CLIENT_ID
vi.unstubAllGlobals()
- await clearGoogleCalendarTokens()
+ await clearGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)
})
describe('watchCalendar', () => {
diff --git a/apps/desktop/src/main/calendar/google/client.ts b/apps/desktop/src/main/calendar/google/client.ts
index da4665070..ea2d30d4c 100644
--- a/apps/desktop/src/main/calendar/google/client.ts
+++ b/apps/desktop/src/main/calendar/google/client.ts
@@ -1,6 +1,10 @@
import { z } from 'zod'
import { createLogger } from '../../lib/logger'
-import { getGoogleCalendarTokens, storeGoogleCalendarTokens } from './keychain'
+import {
+ LEGACY_DEFAULT_ACCOUNT_ID,
+ getGoogleCalendarTokens,
+ storeGoogleCalendarTokens
+} from './keychain'
import { userMessageForCalendarApiError, userMessageForTokenEndpointError } from './oauth-errors'
import type {
GoogleCalendarClient,
@@ -133,9 +137,7 @@ function mapCalendar(item: z.infer): Google
}
}
-function resolveOriginalStartTime(
- raw: z.infer
-): string | null {
+function resolveOriginalStartTime(raw: z.infer): string | null {
const ost = raw.originalStartTime
if (!ost) return null
if (ost.dateTime) return ost.dateTime
@@ -307,7 +309,7 @@ function toGoogleEventPayload(event: GoogleCalendarUpsertEventInput): Record {
const clientId = resolveGoogleClientId()
const clientSecret = resolveGoogleClientSecret()
- const { refreshToken } = await getGoogleCalendarTokens()
+ const { refreshToken } = await getGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)
if (!refreshToken) {
throw new Error('Google Calendar is not connected on this device')
}
@@ -357,6 +359,7 @@ async function refreshAccessTokenInner(): Promise {
const parsed = GoogleTokenRefreshSchema.parse(await response.json())
await storeGoogleCalendarTokens({
+ accountId: LEGACY_DEFAULT_ACCOUNT_ID,
accessToken: parsed.access_token,
refreshToken
})
@@ -407,7 +410,7 @@ async function withAuthorizedResponse(
},
retry = true
): Promise {
- const tokens = await getGoogleCalendarTokens()
+ const tokens = await getGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)
const accessToken = tokens.accessToken ?? (await refreshAccessToken())
const url = new URL(`${GOOGLE_API_BASE}${input.path}`)
diff --git a/apps/desktop/src/main/calendar/google/keychain.test.ts b/apps/desktop/src/main/calendar/google/keychain.test.ts
new file mode 100644
index 000000000..3538ac4b6
--- /dev/null
+++ b/apps/desktop/src/main/calendar/google/keychain.test.ts
@@ -0,0 +1,131 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import keytar from 'keytar'
+
+vi.mock('keytar', () => ({
+ default: {
+ setPassword: vi.fn(),
+ getPassword: vi.fn(),
+ deletePassword: vi.fn()
+ }
+}))
+
+import {
+ clearGoogleCalendarTokens,
+ getAccountKey,
+ getGoogleCalendarTokens,
+ hasGoogleCalendarTokens,
+ storeGoogleCalendarTokens
+} from './keychain'
+
+describe('google calendar keychain — multi-account partitioning', () => {
+ const keytarStore = new Map()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ keytarStore.clear()
+ delete process.env.MEMRY_DEVICE
+
+ vi.mocked(keytar.setPassword).mockImplementation(async (service, account, value) => {
+ keytarStore.set(`${service}:${account}`, value)
+ })
+ vi.mocked(keytar.getPassword).mockImplementation(async (service, account) => {
+ return keytarStore.get(`${service}:${account}`) ?? null
+ })
+ vi.mocked(keytar.deletePassword).mockImplementation(async (service, account) => {
+ return keytarStore.delete(`${service}:${account}`)
+ })
+ })
+
+ afterEach(() => {
+ delete process.env.MEMRY_DEVICE
+ })
+
+ it('stores tokens for different accountIds in distinct keytar slots', async () => {
+ await storeGoogleCalendarTokens({
+ accountId: 'alice@example.com',
+ accessToken: 'alice-access',
+ refreshToken: 'alice-refresh'
+ })
+ await storeGoogleCalendarTokens({
+ accountId: 'bob@example.com',
+ accessToken: 'bob-access',
+ refreshToken: 'bob-refresh'
+ })
+
+ expect(await getGoogleCalendarTokens('alice@example.com')).toEqual({
+ accessToken: 'alice-access',
+ refreshToken: 'alice-refresh'
+ })
+ expect(await getGoogleCalendarTokens('bob@example.com')).toEqual({
+ accessToken: 'bob-access',
+ refreshToken: 'bob-refresh'
+ })
+
+ // Four distinct (service, account) slots written: 2 accounts * 2 token kinds.
+ const writeCalls = vi.mocked(keytar.setPassword).mock.calls
+ const accounts = writeCalls.map((call) => call[1])
+ expect(new Set(accounts).size).toBe(4)
+ })
+
+ it('returns null tokens when accountId has no stored credentials', async () => {
+ const tokens = await getGoogleCalendarTokens('nobody@example.com')
+ expect(tokens).toEqual({ accessToken: null, refreshToken: null })
+ expect(await hasGoogleCalendarTokens('nobody@example.com')).toBe(false)
+ })
+
+ it('clearing one account does not affect another account', async () => {
+ await storeGoogleCalendarTokens({
+ accountId: 'alice@example.com',
+ accessToken: 'a1',
+ refreshToken: 'r1'
+ })
+ await storeGoogleCalendarTokens({
+ accountId: 'bob@example.com',
+ accessToken: 'a2',
+ refreshToken: 'r2'
+ })
+
+ await clearGoogleCalendarTokens('alice@example.com')
+
+ expect(await getGoogleCalendarTokens('alice@example.com')).toEqual({
+ accessToken: null,
+ refreshToken: null
+ })
+ expect(await getGoogleCalendarTokens('bob@example.com')).toEqual({
+ accessToken: 'a2',
+ refreshToken: 'r2'
+ })
+ expect(await hasGoogleCalendarTokens('alice@example.com')).toBe(false)
+ expect(await hasGoogleCalendarTokens('bob@example.com')).toBe(true)
+ })
+
+ it('getAccountKey returns a deterministic keytar account string including accountId and kind', () => {
+ const aliceAccess = getAccountKey('alice@example.com', 'access-token')
+ const aliceRefresh = getAccountKey('alice@example.com', 'refresh-token')
+ const bobAccess = getAccountKey('bob@example.com', 'access-token')
+
+ expect(aliceAccess).toContain('alice@example.com')
+ expect(aliceAccess).toContain('access-token')
+ expect(aliceRefresh).toContain('refresh-token')
+ expect(aliceAccess).not.toEqual(bobAccess)
+ // Kind substring stable for pattern-match assertions elsewhere.
+ expect(aliceAccess.startsWith('access-token')).toBe(true)
+ expect(aliceRefresh.startsWith('refresh-token')).toBe(true)
+ })
+
+ it('respects MEMRY_DEVICE env suffix on top of accountId partitioning', async () => {
+ process.env.MEMRY_DEVICE = 'devA'
+
+ await storeGoogleCalendarTokens({
+ accountId: 'alice@example.com',
+ accessToken: 'devA-alice-access',
+ refreshToken: 'devA-alice-refresh'
+ })
+
+ const writes = vi.mocked(keytar.setPassword).mock.calls
+ for (const [, account] of writes) {
+ expect(account).toContain('alice@example.com')
+ expect(account).toContain('devA')
+ }
+ })
+})
diff --git a/apps/desktop/src/main/calendar/google/keychain.ts b/apps/desktop/src/main/calendar/google/keychain.ts
index 184e0d131..3f404a03b 100644
--- a/apps/desktop/src/main/calendar/google/keychain.ts
+++ b/apps/desktop/src/main/calendar/google/keychain.ts
@@ -1,80 +1,94 @@
import keytar from 'keytar'
const SERVICE = 'com.memry.calendar.google'
-const ACCESS_TOKEN_ACCOUNT = 'access-token'
-const REFRESH_TOKEN_ACCOUNT = 'refresh-token'
-function resolveAccount(account: string): string {
+export const LEGACY_DEFAULT_ACCOUNT_ID = '__memry_default__'
+
+export type GoogleTokenKind = 'access-token' | 'refresh-token'
+
+export function getAccountKey(accountId: string, kind: GoogleTokenKind): string {
+ if (!accountId || !accountId.trim()) {
+ throw new Error('getAccountKey requires a non-empty accountId')
+ }
const deviceSuffix = process.env.MEMRY_DEVICE
- return deviceSuffix ? `${account}-${deviceSuffix}` : account
+ const base = `${kind}-${accountId}`
+ return deviceSuffix ? `${base}-${deviceSuffix}` : base
}
-async function setPassword(account: string, value: string | null): Promise {
- const resolvedAccount = resolveAccount(account)
+async function setPassword(
+ accountId: string,
+ kind: GoogleTokenKind,
+ value: string | null
+): Promise {
+ const account = getAccountKey(accountId, kind)
try {
if (!value || value.trim().length === 0) {
- await keytar.deletePassword(SERVICE, resolvedAccount)
+ await keytar.deletePassword(SERVICE, account)
return
}
- await keytar.setPassword(SERVICE, resolvedAccount, value.trim())
+ await keytar.setPassword(SERVICE, account, value.trim())
} catch (error) {
throw new Error(
- `Failed to store Google Calendar credential (${resolvedAccount}): ${error instanceof Error ? error.message : 'unknown error'}`
+ `Failed to store Google Calendar credential (${account}): ${error instanceof Error ? error.message : 'unknown error'}`
)
}
}
-async function getPassword(account: string): Promise {
- const resolvedAccount = resolveAccount(account)
+async function getPassword(accountId: string, kind: GoogleTokenKind): Promise {
+ const account = getAccountKey(accountId, kind)
try {
- return await keytar.getPassword(SERVICE, resolvedAccount)
+ return await keytar.getPassword(SERVICE, account)
} catch (error) {
throw new Error(
- `Failed to read Google Calendar credential (${resolvedAccount}): ${error instanceof Error ? error.message : 'unknown error'}`
+ `Failed to read Google Calendar credential (${account}): ${error instanceof Error ? error.message : 'unknown error'}`
)
}
}
-async function deletePassword(account: string): Promise {
- const resolvedAccount = resolveAccount(account)
+async function deletePassword(accountId: string, kind: GoogleTokenKind): Promise {
+ const account = getAccountKey(accountId, kind)
try {
- await keytar.deletePassword(SERVICE, resolvedAccount)
+ await keytar.deletePassword(SERVICE, account)
} catch (error) {
throw new Error(
- `Failed to delete Google Calendar credential (${resolvedAccount}): ${error instanceof Error ? error.message : 'unknown error'}`
+ `Failed to delete Google Calendar credential (${account}): ${error instanceof Error ? error.message : 'unknown error'}`
)
}
}
export async function storeGoogleCalendarTokens(input: {
+ accountId: string
accessToken: string
refreshToken: string
}): Promise {
- await setPassword(ACCESS_TOKEN_ACCOUNT, input.accessToken)
- await setPassword(REFRESH_TOKEN_ACCOUNT, input.refreshToken)
+ await setPassword(input.accountId, 'access-token', input.accessToken)
+ await setPassword(input.accountId, 'refresh-token', input.refreshToken)
}
-export async function getGoogleCalendarTokens(): Promise<{
+export async function getGoogleCalendarTokens(accountId: string): Promise<{
accessToken: string | null
refreshToken: string | null
}> {
const [accessToken, refreshToken] = await Promise.all([
- getPassword(ACCESS_TOKEN_ACCOUNT),
- getPassword(REFRESH_TOKEN_ACCOUNT)
+ getPassword(accountId, 'access-token'),
+ getPassword(accountId, 'refresh-token')
])
return { accessToken, refreshToken }
}
-export async function hasGoogleCalendarTokens(): Promise {
- const { refreshToken } = await getGoogleCalendarTokens()
+export async function hasGoogleCalendarTokens(accountId: string): Promise {
+ const { refreshToken } = await getGoogleCalendarTokens(accountId)
return typeof refreshToken === 'string' && refreshToken.trim().length > 0
}
-export async function clearGoogleCalendarTokens(): Promise {
- await Promise.all([deletePassword(ACCESS_TOKEN_ACCOUNT), deletePassword(REFRESH_TOKEN_ACCOUNT)])
+export async function clearGoogleCalendarTokens(accountId: string): Promise {
+ await Promise.all([
+ deletePassword(accountId, 'access-token'),
+ deletePassword(accountId, 'refresh-token')
+ ])
}
diff --git a/apps/desktop/src/main/calendar/google/oauth.test.ts b/apps/desktop/src/main/calendar/google/oauth.test.ts
index ed99e8760..1f63f3f0a 100644
--- a/apps/desktop/src/main/calendar/google/oauth.test.ts
+++ b/apps/desktop/src/main/calendar/google/oauth.test.ts
@@ -33,6 +33,7 @@ vi.mock('../../lib/logger', () => ({
import { GOOGLE_CALENDAR_SCOPE, connectGoogleCalendar, disconnectGoogleCalendar } from './oauth'
import {
+ LEGACY_DEFAULT_ACCOUNT_ID,
clearGoogleCalendarTokens,
getGoogleCalendarTokens,
hasGoogleCalendarTokens,
@@ -64,7 +65,7 @@ describe('google calendar oauth', () => {
afterEach(async () => {
delete process.env.GOOGLE_CALENDAR_CLIENT_ID
vi.unstubAllGlobals()
- await clearGoogleCalendarTokens()
+ await clearGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)
})
it('uses a provider-specific loopback OAuth flow with Calendar scopes and stores tokens in a separate device-local keychain', async () => {
@@ -130,7 +131,7 @@ describe('google calendar oauth', () => {
})
const result = await connectGoogleCalendar()
- const tokens = await getGoogleCalendarTokens()
+ const tokens = await getGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)
expect(result).toEqual({
account: {
@@ -150,7 +151,7 @@ describe('google calendar oauth', () => {
accessToken: 'google-access-token',
refreshToken: 'google-refresh-token'
})
- expect(await hasGoogleCalendarTokens()).toBe(true)
+ expect(await hasGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)).toBe(true)
expect(keytar.setPassword).toHaveBeenCalledWith(
'com.memry.calendar.google',
expect.stringContaining('access-token'),
@@ -213,7 +214,7 @@ describe('google calendar oauth', () => {
})
)
- expect(await hasGoogleCalendarTokens()).toBe(false)
+ expect(await hasGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)).toBe(false)
})
it('rejects the callback when the OAuth state does not match', async () => {
@@ -234,24 +235,25 @@ describe('google calendar oauth', () => {
'Invalid or expired Google Calendar OAuth state'
)
expect(fetchMock).not.toHaveBeenCalled()
- expect(await hasGoogleCalendarTokens()).toBe(false)
+ expect(await hasGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)).toBe(false)
})
it('stores and clears Google Calendar tokens independently from sync auth keychain entries', async () => {
await storeGoogleCalendarTokens({
+ accountId: LEGACY_DEFAULT_ACCOUNT_ID,
accessToken: 'manual-access-token',
refreshToken: 'manual-refresh-token'
})
- expect(await hasGoogleCalendarTokens()).toBe(true)
- expect(await getGoogleCalendarTokens()).toEqual({
+ expect(await hasGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)).toBe(true)
+ expect(await getGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)).toEqual({
accessToken: 'manual-access-token',
refreshToken: 'manual-refresh-token'
})
await disconnectGoogleCalendar()
- expect(await getGoogleCalendarTokens()).toEqual({
+ expect(await getGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)).toEqual({
accessToken: null,
refreshToken: null
})
diff --git a/apps/desktop/src/main/calendar/google/oauth.ts b/apps/desktop/src/main/calendar/google/oauth.ts
index 10bb1b757..eb7c175a5 100644
--- a/apps/desktop/src/main/calendar/google/oauth.ts
+++ b/apps/desktop/src/main/calendar/google/oauth.ts
@@ -6,6 +6,7 @@ import { createLogger } from '../../lib/logger'
import type { DataDb } from '../../database/types'
import { listCalendarSources } from '../repositories/calendar-sources-repository'
import {
+ LEGACY_DEFAULT_ACCOUNT_ID,
clearGoogleCalendarTokens,
getGoogleCalendarTokens,
storeGoogleCalendarTokens
@@ -409,13 +410,14 @@ export async function connectGoogleCalendar(): Promise
throw new Error(CALENDAR_SCOPE_NOT_GRANTED_MESSAGE)
}
- const existingTokens = await getGoogleCalendarTokens()
+ const existingTokens = await getGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)
const refreshToken = tokenResponse.refresh_token ?? existingTokens.refreshToken
if (!refreshToken) {
throw new Error('Google Calendar OAuth did not return a refresh token')
}
await storeGoogleCalendarTokens({
+ accountId: LEGACY_DEFAULT_ACCOUNT_ID,
accessToken: tokenResponse.access_token,
refreshToken
})
@@ -441,7 +443,7 @@ export async function connectGoogleCalendar(): Promise
}
export async function disconnectGoogleCalendar(): Promise {
- const { refreshToken } = await getGoogleCalendarTokens()
+ const { refreshToken } = await getGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)
if (refreshToken) {
try {
@@ -455,11 +457,11 @@ export async function disconnectGoogleCalendar(): Promise {
}
}
- await clearGoogleCalendarTokens()
+ await clearGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)
}
export async function hasGoogleCalendarLocalAuth(): Promise {
- const { refreshToken } = await getGoogleCalendarTokens()
+ const { refreshToken } = await getGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)
return typeof refreshToken === 'string' && refreshToken.trim().length > 0
}
From b33960b87d55ee49cd4f94d11e9682c7e721f27d Mon Sep 17 00:00:00 2001
From: Kaan Karaca
Date: Sun, 19 Apr 2026 21:33:45 +0300
Subject: [PATCH 2/9] feat(calendar): OAuth flow uses Google profile email as
accountId (M6 T2)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds the userinfo fetch (`oauth2/v2/userinfo`) right after token
exchange and uses the returned `email` as the canonical accountId for
both keychain partitioning and the `calendar_sources.kind='account'`
row. Connecting a second Google account on the same device now creates
a second source row instead of overwriting the first; tokens land in
distinct keychain slots; either account can be disconnected on its own.
Multi-account plumbing:
- `connectGoogleCalendar()` returns `{ accountId, account: { email, … } }`.
- `disconnectGoogleCalendar(accountId)` is now per-account; revokes and
clears keychain only for that account. Calendar handler enumerates
every connected Google account and disconnects each in turn.
- `createGoogleCalendarClient({ accountId })` requires accountId at
construction; per-account `pendingRefreshes` map prevents
cross-account token refresh races.
- New `resolveDefaultGoogleAccountId(db)` / `listGoogleAccountIds(db)`
/ `hasAnyGoogleCalendarLocalAuth(db)` helpers in `oauth.ts` for
callers that need to address "any connected account" until M6 T4
threads per-target routing through push and sync.
- `session-teardown.ts` enumerates accounts on logout instead of
hitting one fixed slot.
- `LIST_GOOGLE_CALENDARS` IPC returns an empty list when no Google
account is connected (instead of throwing on the missing default).
Tests:
- Existing successful-connect scenario asserts the userinfo fetch and
email-keyed keychain.
- New scenario: `connecting a second Google account stores tokens
under a distinct accountId without overwriting the first` —
disconnects account A and confirms account B's tokens survive.
- `calendar-handlers.test.ts` mocks the new oauth exports and the
connect mock returns the M6 shape.
Closes most of G9; the remaining pieces (sync-health surface, per-
calendar push routing, source-row tombstone scoping) land in T3–T6.
---
.../src/main/calendar/google/client.test.ts | 14 +--
.../src/main/calendar/google/client.ts | 67 +++++-----
.../src/main/calendar/google/oauth.test.ts | 118 +++++++++++++++---
.../desktop/src/main/calendar/google/oauth.ts | 89 +++++++++++--
.../src/main/calendar/google/push-runtime.ts | 8 +-
.../src/main/calendar/google/sync-service.ts | 22 ++--
.../src/main/ipc/calendar-handlers.test.ts | 23 +++-
.../desktop/src/main/ipc/calendar-handlers.ts | 37 ++++--
.../desktop/src/main/sync/session-teardown.ts | 16 ++-
9 files changed, 300 insertions(+), 94 deletions(-)
diff --git a/apps/desktop/src/main/calendar/google/client.test.ts b/apps/desktop/src/main/calendar/google/client.test.ts
index f49952588..408b35723 100644
--- a/apps/desktop/src/main/calendar/google/client.test.ts
+++ b/apps/desktop/src/main/calendar/google/client.test.ts
@@ -103,7 +103,7 @@ describe('google calendar client — push channels (Task 7)', () => {
)
})
- const client = createGoogleCalendarClient()
+ const client = createGoogleCalendarClient({ accountId: LEGACY_DEFAULT_ACCOUNT_ID })
const result = await client.watchCalendar({
calendarId: 'primary@group.calendar.google.com',
channelId: 'channel-abc',
@@ -129,7 +129,7 @@ describe('google calendar client — push channels (Task 7)', () => {
)
)
- const client = createGoogleCalendarClient()
+ const client = createGoogleCalendarClient({ accountId: LEGACY_DEFAULT_ACCOUNT_ID })
await expect(
client.watchCalendar({
calendarId: 'primary',
@@ -153,7 +153,7 @@ describe('google calendar client — push channels (Task 7)', () => {
return new Response(null, { status: 204 })
})
- const client = createGoogleCalendarClient()
+ const client = createGoogleCalendarClient({ accountId: LEGACY_DEFAULT_ACCOUNT_ID })
await expect(
client.stopChannel({ channelId: 'channel-abc', resourceId: 'resource-123' })
).resolves.toBeUndefined()
@@ -167,7 +167,7 @@ describe('google calendar client — push channels (Task 7)', () => {
)
)
- const client = createGoogleCalendarClient()
+ const client = createGoogleCalendarClient({ accountId: LEGACY_DEFAULT_ACCOUNT_ID })
await expect(
client.stopChannel({ channelId: 'stale', resourceId: 'stale-resource' })
).resolves.toBeUndefined()
@@ -176,7 +176,7 @@ describe('google calendar client — push channels (Task 7)', () => {
it('throws for non-404 errors (e.g. 500)', async () => {
fetchMock.mockResolvedValue(new Response('oops', { status: 500 }))
- const client = createGoogleCalendarClient()
+ const client = createGoogleCalendarClient({ accountId: LEGACY_DEFAULT_ACCOUNT_ID })
await expect(client.stopChannel({ channelId: 'c', resourceId: 'r' })).rejects.toThrow()
})
})
@@ -209,7 +209,7 @@ describe('google calendar client — push channels (Task 7)', () => {
it('#given an all-day recurring exception #when upserted #then emits originalStartTime as { date } (no dateTime)', async () => {
const captured = captureBody()
- const client = createGoogleCalendarClient()
+ const client = createGoogleCalendarClient({ accountId: LEGACY_DEFAULT_ACCOUNT_ID })
await client.upsertEvent({
calendarId: 'primary',
eventId: null,
@@ -238,7 +238,7 @@ describe('google calendar client — push channels (Task 7)', () => {
it('#given a timed recurring exception #when upserted #then emits originalStartTime as { dateTime, timeZone }', async () => {
const captured = captureBody()
- const client = createGoogleCalendarClient()
+ const client = createGoogleCalendarClient({ accountId: LEGACY_DEFAULT_ACCOUNT_ID })
await client.upsertEvent({
calendarId: 'primary',
eventId: null,
diff --git a/apps/desktop/src/main/calendar/google/client.ts b/apps/desktop/src/main/calendar/google/client.ts
index ea2d30d4c..171c2dcc3 100644
--- a/apps/desktop/src/main/calendar/google/client.ts
+++ b/apps/desktop/src/main/calendar/google/client.ts
@@ -1,10 +1,6 @@
import { z } from 'zod'
import { createLogger } from '../../lib/logger'
-import {
- LEGACY_DEFAULT_ACCOUNT_ID,
- getGoogleCalendarTokens,
- storeGoogleCalendarTokens
-} from './keychain'
+import { getGoogleCalendarTokens, storeGoogleCalendarTokens } from './keychain'
import { userMessageForCalendarApiError, userMessageForTokenEndpointError } from './oauth-errors'
import type {
GoogleCalendarClient,
@@ -17,7 +13,7 @@ const log = createLogger('Calendar:GoogleClient')
const GOOGLE_API_BASE = 'https://www.googleapis.com/calendar/v3'
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'
-let pendingRefresh: Promise | null = null
+const pendingRefreshes = new Map>()
const GoogleCalendarListItemSchema = z.object({
id: z.string().min(1),
@@ -306,12 +302,12 @@ function toGoogleEventPayload(event: GoogleCalendarUpsertEventInput): Record {
+async function refreshAccessTokenInner(accountId: string): Promise {
const clientId = resolveGoogleClientId()
const clientSecret = resolveGoogleClientSecret()
- const { refreshToken } = await getGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)
+ const { refreshToken } = await getGoogleCalendarTokens(accountId)
if (!refreshToken) {
- throw new Error('Google Calendar is not connected on this device')
+ throw new Error(`Google Calendar is not connected for account ${accountId}`)
}
const params = new URLSearchParams({
@@ -359,19 +355,21 @@ async function refreshAccessTokenInner(): Promise {
const parsed = GoogleTokenRefreshSchema.parse(await response.json())
await storeGoogleCalendarTokens({
- accountId: LEGACY_DEFAULT_ACCOUNT_ID,
+ accountId,
accessToken: parsed.access_token,
refreshToken
})
return parsed.access_token
}
-async function refreshAccessToken(): Promise {
- if (pendingRefresh) return pendingRefresh
- pendingRefresh = refreshAccessTokenInner().finally(() => {
- pendingRefresh = null
+async function refreshAccessToken(accountId: string): Promise {
+ const existing = pendingRefreshes.get(accountId)
+ if (existing) return existing
+ const promise = refreshAccessTokenInner(accountId).finally(() => {
+ pendingRefreshes.delete(accountId)
})
- return pendingRefresh
+ pendingRefreshes.set(accountId, promise)
+ return promise
}
async function throwCalendarApiFailure(response: Response, operation: string): Promise {
@@ -403,6 +401,7 @@ async function throwCalendarApiFailure(response: Response, operation: string): P
}
async function withAuthorizedResponse(
+ accountId: string,
input: {
path: string
init?: RequestInit
@@ -410,8 +409,8 @@ async function withAuthorizedResponse(
},
retry = true
): Promise {
- const tokens = await getGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)
- const accessToken = tokens.accessToken ?? (await refreshAccessToken())
+ const tokens = await getGoogleCalendarTokens(accountId)
+ const accessToken = tokens.accessToken ?? (await refreshAccessToken(accountId))
const url = new URL(`${GOOGLE_API_BASE}${input.path}`)
for (const [key, value] of Object.entries(input.query ?? {})) {
@@ -430,18 +429,28 @@ async function withAuthorizedResponse(
})
if (response.status === 401 && retry) {
- log.warn('Google Calendar access token expired, refreshing')
- await refreshAccessToken()
- return await withAuthorizedResponse(input, false)
+ log.warn('Google Calendar access token expired, refreshing', { accountId })
+ await refreshAccessToken(accountId)
+ return await withAuthorizedResponse(accountId, input, false)
}
return response
}
-export function createGoogleCalendarClient(): GoogleCalendarClient {
+export interface CreateGoogleCalendarClientInput {
+ accountId: string
+}
+
+export function createGoogleCalendarClient(
+ input: CreateGoogleCalendarClientInput
+): GoogleCalendarClient {
+ const { accountId } = input
+ if (!accountId || !accountId.trim()) {
+ throw new Error('createGoogleCalendarClient requires a non-empty accountId')
+ }
return {
async listCalendars(): Promise {
- const response = await withAuthorizedResponse({
+ const response = await withAuthorizedResponse(accountId, {
path: '/users/me/calendarList'
})
@@ -454,7 +463,7 @@ export function createGoogleCalendarClient(): GoogleCalendarClient {
},
async createCalendar(input): Promise {
- const response = await withAuthorizedResponse({
+ const response = await withAuthorizedResponse(accountId, {
path: '/calendars',
init: {
method: 'POST',
@@ -478,7 +487,7 @@ export function createGoogleCalendarClient(): GoogleCalendarClient {
}> {
const useSyncToken = Boolean(input.syncCursor)
- const response = await withAuthorizedResponse({
+ const response = await withAuthorizedResponse(accountId, {
path: `/calendars/${encodeURIComponent(input.calendarId)}/events`,
query: useSyncToken
? {
@@ -510,7 +519,7 @@ export function createGoogleCalendarClient(): GoogleCalendarClient {
},
async getEvent(input): Promise {
- const response = await withAuthorizedResponse({
+ const response = await withAuthorizedResponse(accountId, {
path: `/calendars/${encodeURIComponent(input.calendarId)}/events/${encodeURIComponent(input.eventId)}`
})
@@ -527,7 +536,7 @@ export function createGoogleCalendarClient(): GoogleCalendarClient {
if (isUpdate && input.ifMatch) {
headers['If-Match'] = input.ifMatch
}
- const response = await withAuthorizedResponse({
+ const response = await withAuthorizedResponse(accountId, {
path: isUpdate
? `/calendars/${encodeURIComponent(input.calendarId)}/events/${encodeURIComponent(input.eventId!)}`
: `/calendars/${encodeURIComponent(input.calendarId)}/events`,
@@ -546,7 +555,7 @@ export function createGoogleCalendarClient(): GoogleCalendarClient {
},
async deleteEvent(input): Promise {
- const response = await withAuthorizedResponse({
+ const response = await withAuthorizedResponse(accountId, {
path: `/calendars/${encodeURIComponent(input.calendarId)}/events/${encodeURIComponent(input.eventId)}`,
init: {
method: 'DELETE'
@@ -560,7 +569,7 @@ export function createGoogleCalendarClient(): GoogleCalendarClient {
async watchCalendar(input): Promise<{ resourceId: string; expiration: number }> {
const expirationMs = Date.now() + input.ttlSeconds * 1000
- const response = await withAuthorizedResponse({
+ const response = await withAuthorizedResponse(accountId, {
path: `/calendars/${encodeURIComponent(input.calendarId)}/events/watch`,
init: {
method: 'POST',
@@ -590,7 +599,7 @@ export function createGoogleCalendarClient(): GoogleCalendarClient {
},
async stopChannel(input): Promise {
- const response = await withAuthorizedResponse({
+ const response = await withAuthorizedResponse(accountId, {
path: '/channels/stop',
init: {
method: 'POST',
diff --git a/apps/desktop/src/main/calendar/google/oauth.test.ts b/apps/desktop/src/main/calendar/google/oauth.test.ts
index 1f63f3f0a..f2775b878 100644
--- a/apps/desktop/src/main/calendar/google/oauth.test.ts
+++ b/apps/desktop/src/main/calendar/google/oauth.test.ts
@@ -90,6 +90,23 @@ describe('google calendar oauth', () => {
)
}
+ if (url === 'https://www.googleapis.com/oauth2/v2/userinfo') {
+ expect(init?.headers).toEqual(
+ expect.objectContaining({
+ Authorization: 'Bearer google-access-token'
+ })
+ )
+ return new Response(
+ JSON.stringify({
+ email: 'user@example.com',
+ verified_email: true,
+ name: 'User Example',
+ picture: 'https://example.com/avatar.png'
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
+ )
+ }
+
if (url === 'https://www.googleapis.com/calendar/v3/users/me/calendarList/primary') {
expect(init?.headers).toEqual(
expect.objectContaining({
@@ -131,11 +148,13 @@ describe('google calendar oauth', () => {
})
const result = await connectGoogleCalendar()
- const tokens = await getGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)
+ const tokens = await getGoogleCalendarTokens('user@example.com')
expect(result).toEqual({
+ accountId: 'user@example.com',
account: {
remoteId: 'user@example.com',
+ email: 'user@example.com',
title: 'User Example',
timezone: 'Europe/Istanbul'
},
@@ -151,15 +170,15 @@ describe('google calendar oauth', () => {
accessToken: 'google-access-token',
refreshToken: 'google-refresh-token'
})
- expect(await hasGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)).toBe(true)
+ expect(await hasGoogleCalendarTokens('user@example.com')).toBe(true)
expect(keytar.setPassword).toHaveBeenCalledWith(
'com.memry.calendar.google',
- expect.stringContaining('access-token'),
+ expect.stringContaining('user@example.com'),
'google-access-token'
)
expect(keytar.setPassword).toHaveBeenCalledWith(
'com.memry.calendar.google',
- expect.stringContaining('refresh-token'),
+ expect.stringContaining('user@example.com'),
'google-refresh-token'
)
})
@@ -239,31 +258,102 @@ describe('google calendar oauth', () => {
})
it('stores and clears Google Calendar tokens independently from sync auth keychain entries', async () => {
+ fetchMock.mockImplementation(async () => new Response('', { status: 200 }))
+
await storeGoogleCalendarTokens({
- accountId: LEGACY_DEFAULT_ACCOUNT_ID,
+ accountId: 'manual@example.com',
accessToken: 'manual-access-token',
refreshToken: 'manual-refresh-token'
})
- expect(await hasGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)).toBe(true)
- expect(await getGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)).toEqual({
+ expect(await hasGoogleCalendarTokens('manual@example.com')).toBe(true)
+ expect(await getGoogleCalendarTokens('manual@example.com')).toEqual({
accessToken: 'manual-access-token',
refreshToken: 'manual-refresh-token'
})
- await disconnectGoogleCalendar()
+ await disconnectGoogleCalendar('manual@example.com')
- expect(await getGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)).toEqual({
+ expect(await getGoogleCalendarTokens('manual@example.com')).toEqual({
accessToken: null,
refreshToken: null
})
expect(keytar.deletePassword).toHaveBeenCalledWith(
'com.memry.calendar.google',
- expect.stringContaining('access-token')
- )
- expect(keytar.deletePassword).toHaveBeenCalledWith(
- 'com.memry.calendar.google',
- expect.stringContaining('refresh-token')
+ expect.stringContaining('manual@example.com')
)
})
+
+ it('connecting a second Google account stores tokens under a distinct accountId without overwriting the first', async () => {
+ let userInfoCalls = 0
+ fetchMock.mockImplementation(async (input) => {
+ const url = String(input)
+
+ if (url === 'https://oauth2.googleapis.com/token') {
+ return new Response(
+ JSON.stringify({
+ access_token: userInfoCalls === 0 ? 'first-access' : 'second-access',
+ refresh_token: userInfoCalls === 0 ? 'first-refresh' : 'second-refresh',
+ expires_in: 3600,
+ scope: `openid email profile ${GOOGLE_CALENDAR_SCOPE}`,
+ token_type: 'Bearer'
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
+ )
+ }
+
+ if (url === 'https://www.googleapis.com/oauth2/v2/userinfo') {
+ const email = userInfoCalls === 0 ? 'alice@example.com' : 'bob@example.com'
+ userInfoCalls++
+ return new Response(
+ JSON.stringify({ email, verified_email: true, name: email.split('@')[0] }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
+ )
+ }
+
+ if (url === 'https://www.googleapis.com/calendar/v3/users/me/calendarList/primary') {
+ const email = userInfoCalls === 1 ? 'alice@example.com' : 'bob@example.com'
+ return new Response(
+ JSON.stringify({
+ id: email,
+ summary: email,
+ timeZone: 'UTC',
+ primary: true
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
+ )
+ }
+
+ throw new Error(`Unexpected fetch call: ${url}`)
+ })
+
+ mockOpenExternal.mockImplementation(async (authUrl: string) => {
+ const parsed = new URL(authUrl)
+ const redirectUri = parsed.searchParams.get('redirect_uri')
+ const state = parsed.searchParams.get('state')
+ setTimeout(() => {
+ http.get(`${redirectUri}?code=google-auth-code&state=${state}`)
+ }, 0)
+ })
+
+ const first = await connectGoogleCalendar()
+ const second = await connectGoogleCalendar()
+
+ expect(first.accountId).toBe('alice@example.com')
+ expect(second.accountId).toBe('bob@example.com')
+
+ expect(await getGoogleCalendarTokens('alice@example.com')).toEqual({
+ accessToken: 'first-access',
+ refreshToken: 'first-refresh'
+ })
+ expect(await getGoogleCalendarTokens('bob@example.com')).toEqual({
+ accessToken: 'second-access',
+ refreshToken: 'second-refresh'
+ })
+
+ await disconnectGoogleCalendar('alice@example.com')
+
+ expect(await hasGoogleCalendarTokens('alice@example.com')).toBe(false)
+ expect(await hasGoogleCalendarTokens('bob@example.com')).toBe(true)
+ })
})
diff --git a/apps/desktop/src/main/calendar/google/oauth.ts b/apps/desktop/src/main/calendar/google/oauth.ts
index eb7c175a5..50350d437 100644
--- a/apps/desktop/src/main/calendar/google/oauth.ts
+++ b/apps/desktop/src/main/calendar/google/oauth.ts
@@ -6,7 +6,6 @@ import { createLogger } from '../../lib/logger'
import type { DataDb } from '../../database/types'
import { listCalendarSources } from '../repositories/calendar-sources-repository'
import {
- LEGACY_DEFAULT_ACCOUNT_ID,
clearGoogleCalendarTokens,
getGoogleCalendarTokens,
storeGoogleCalendarTokens
@@ -24,6 +23,7 @@ const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'
const GOOGLE_REVOKE_URL = 'https://oauth2.googleapis.com/revoke'
const GOOGLE_PRIMARY_CALENDAR_URL =
'https://www.googleapis.com/calendar/v3/users/me/calendarList/primary'
+const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo'
const OAUTH_TIMEOUT_MS = 10 * 60 * 1000
export const GOOGLE_CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar'
@@ -46,6 +46,12 @@ const GooglePrimaryCalendarSchema = z.object({
primary: z.boolean().optional()
})
+const GoogleUserInfoSchema = z.object({
+ email: z.string().min(1),
+ verified_email: z.boolean().optional(),
+ name: z.string().optional()
+})
+
interface GoogleOAuthSession {
state: string
redirectUri: string
@@ -54,8 +60,10 @@ interface GoogleOAuthSession {
}
export interface GoogleCalendarConnection {
+ accountId: string
account: {
remoteId: string
+ email: string
title: string
timezone: string | null
}
@@ -243,6 +251,31 @@ async function exchangeCodeForTokens(input: {
return GoogleTokenResponseSchema.parse(await response.json())
}
+async function fetchUserInfo(accessToken: string): Promise> {
+ const response = await fetch(GOOGLE_USERINFO_URL, {
+ headers: { Authorization: `Bearer ${accessToken}` }
+ })
+
+ if (!response.ok) {
+ const body = await response.text().catch(() => '')
+ let apiStatus: string | undefined
+ try {
+ const parsedBody = JSON.parse(body) as { error?: { status?: string } }
+ apiStatus = parsedBody.error?.status
+ } catch {
+ // body may be plain text or empty
+ }
+ log.error('Failed to fetch Google userinfo', {
+ status: response.status,
+ apiStatus,
+ body: body.slice(0, 500)
+ })
+ throw new Error(userMessageForCalendarApiError({ status: response.status, apiStatus }))
+ }
+
+ return GoogleUserInfoSchema.parse(await response.json())
+}
+
async function fetchPrimaryCalendar(
accessToken: string
): Promise> {
@@ -410,14 +443,18 @@ export async function connectGoogleCalendar(): Promise
throw new Error(CALENDAR_SCOPE_NOT_GRANTED_MESSAGE)
}
- const existingTokens = await getGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)
+ const userInfo = await fetchUserInfo(tokenResponse.access_token)
+ const accountId = userInfo.email
+ log.info('Resolved Google account identity', { accountId })
+
+ const existingTokens = await getGoogleCalendarTokens(accountId)
const refreshToken = tokenResponse.refresh_token ?? existingTokens.refreshToken
if (!refreshToken) {
throw new Error('Google Calendar OAuth did not return a refresh token')
}
await storeGoogleCalendarTokens({
- accountId: LEGACY_DEFAULT_ACCOUNT_ID,
+ accountId,
accessToken: tokenResponse.access_token,
refreshToken
})
@@ -427,9 +464,11 @@ export async function connectGoogleCalendar(): Promise
const timezone = primaryCalendar.timeZone ?? null
return {
+ accountId,
account: {
remoteId: primaryCalendar.id,
- title,
+ email: userInfo.email,
+ title: userInfo.name ?? title,
timezone
},
primaryCalendar: {
@@ -442,8 +481,8 @@ export async function connectGoogleCalendar(): Promise
}
}
-export async function disconnectGoogleCalendar(): Promise {
- const { refreshToken } = await getGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)
+export async function disconnectGoogleCalendar(accountId: string): Promise {
+ const { refreshToken } = await getGoogleCalendarTokens(accountId)
if (refreshToken) {
try {
@@ -453,22 +492,46 @@ export async function disconnectGoogleCalendar(): Promise {
body: new URLSearchParams({ token: refreshToken })
})
} catch (error) {
- log.warn('Failed to revoke Google Calendar token (non-blocking)', error)
+ log.warn('Failed to revoke Google Calendar token (non-blocking)', { accountId, error })
}
}
- await clearGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)
+ await clearGoogleCalendarTokens(accountId)
}
-export async function hasGoogleCalendarLocalAuth(): Promise {
- const { refreshToken } = await getGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)
+export async function hasGoogleCalendarLocalAuth(accountId: string): Promise {
+ const { refreshToken } = await getGoogleCalendarTokens(accountId)
return typeof refreshToken === 'string' && refreshToken.trim().length > 0
}
-export async function hasGoogleCalendarConnection(db: DataDb): Promise {
- if (!(await hasGoogleCalendarLocalAuth())) return false
+export function resolveDefaultGoogleAccountId(db: DataDb): string | null {
+ const accounts = listCalendarSources(db, { provider: 'google', kind: 'account' })
+ for (const account of accounts) {
+ if (account.accountId) return account.accountId
+ }
+ return null
+}
+
+export function listGoogleAccountIds(db: DataDb): string[] {
+ const accounts = listCalendarSources(db, { provider: 'google', kind: 'account' })
+ const ids: string[] = []
+ for (const account of accounts) {
+ if (account.accountId) ids.push(account.accountId)
+ }
+ return ids
+}
+
+export async function hasAnyGoogleCalendarLocalAuth(db: DataDb): Promise {
const accounts = listCalendarSources(db, { provider: 'google', kind: 'account' })
- return accounts.length > 0
+ for (const account of accounts) {
+ if (!account.accountId) continue
+ if (await hasGoogleCalendarLocalAuth(account.accountId)) return true
+ }
+ return false
+}
+
+export async function hasGoogleCalendarConnection(db: DataDb): Promise {
+ return await hasAnyGoogleCalendarLocalAuth(db)
}
export function buildGoogleCalendarAuthUrl(input: {
diff --git a/apps/desktop/src/main/calendar/google/push-runtime.ts b/apps/desktop/src/main/calendar/google/push-runtime.ts
index 2715f7a3b..61f06f864 100644
--- a/apps/desktop/src/main/calendar/google/push-runtime.ts
+++ b/apps/desktop/src/main/calendar/google/push-runtime.ts
@@ -3,6 +3,8 @@ import { createLogger } from '../../lib/logger'
import { deleteFromServer, patchToServer, postToServer } from '../../sync/http-client'
import { getValidAccessToken } from '../../sync/token-manager'
import { createGoogleCalendarClient } from './client'
+import { resolveDefaultGoogleAccountId } from './oauth'
+import { requireDatabase } from '../../database'
import { createGoogleChannelManager, type GoogleChannelManager } from './google-channel-manager'
const log = createLogger('Calendar:GooglePushRuntime')
@@ -85,8 +87,12 @@ function buildProductionChannelManager(
): GoogleChannelManager {
const hmacKey = resolveHmacKey()
+ const accountId = resolveDefaultGoogleAccountId(requireDatabase())
+ if (!accountId) {
+ throw new Error('Cannot start Google push channel manager without a connected account')
+ }
return createGoogleChannelManager({
- client: createGoogleCalendarClient(),
+ client: createGoogleCalendarClient({ accountId }),
registerOnServer: async (body) => {
const token = await getValidAccessToken()
if (!token) throw new Error('Not signed in — cannot register push channel')
diff --git a/apps/desktop/src/main/calendar/google/sync-service.ts b/apps/desktop/src/main/calendar/google/sync-service.ts
index 1e98dd9e1..48954ab61 100644
--- a/apps/desktop/src/main/calendar/google/sync-service.ts
+++ b/apps/desktop/src/main/calendar/google/sync-service.ts
@@ -16,7 +16,7 @@ import { initAllFieldClocks } from '../../sync/field-merge'
import { CALENDAR_EVENT_SYNCABLE_FIELDS, mergeCalendarEventFields } from '../field-merge-calendar'
import type { FieldClocks, VectorClock } from '@memry/contracts/sync-api'
import { publishProjectionEvent } from '../../projections'
-import { hasGoogleCalendarConnection } from './oauth'
+import { hasGoogleCalendarConnection, resolveDefaultGoogleAccountId } from './oauth'
import { isMemryUserSignedIn } from '../../sync/auth-state'
import { createGoogleCalendarClient } from './client'
import {
@@ -370,8 +370,16 @@ export async function ensureGoogleCalendarSourceSelected(
return saved
}
-function getGoogleClient(deps?: { client?: GoogleCalendarClient }): GoogleCalendarClient {
- return deps?.client ?? createGoogleCalendarClient()
+function getGoogleClient(
+ db: DataDb,
+ deps?: { client?: GoogleCalendarClient }
+): GoogleCalendarClient {
+ if (deps?.client) return deps.client
+ const accountId = resolveDefaultGoogleAccountId(db)
+ if (!accountId) {
+ throw new Error('Cannot create Google Calendar client without a connected account')
+ }
+ return createGoogleCalendarClient({ accountId })
}
function shouldSourceSyncToGoogleCalendar(db: DataDb, target: CalendarSyncTarget): boolean {
@@ -516,7 +524,7 @@ export async function pushSourceToGoogleCalendar(
>
} = {}
): Promise {
- const client = getGoogleClient(deps as { client?: GoogleCalendarClient })
+ const client = getGoogleClient(db, deps as { client?: GoogleCalendarClient })
const existingBinding = getExistingGoogleBinding(db, target)
const resolvedCalendarId = await resolveTargetCalendarId(db, target, existingBinding, client)
const now = getNow()
@@ -566,7 +574,7 @@ export async function deleteSourceFromGoogleCalendar(
return false
}
- const client = getGoogleClient(deps as { client?: GoogleCalendarClient })
+ const client = getGoogleClient(db, deps as { client?: GoogleCalendarClient })
await client.deleteEvent({
calendarId: existingBinding.remoteCalendarId,
eventId: existingBinding.remoteEventId
@@ -777,7 +785,7 @@ export async function syncGoogleCalendarSource(
throw new Error(`Calendar source not found: ${sourceId}`)
}
- const client = getGoogleClient(deps as { client?: GoogleCalendarClient })
+ const client = getGoogleClient(db, deps as { client?: GoogleCalendarClient })
const now = getNow()
const isInitialSync = !source.syncCursor
@@ -879,7 +887,7 @@ export async function syncGoogleCalendarNow(
syncInFlight = true
try {
- const client = getGoogleClient(deps)
+ const client = getGoogleClient(db, deps)
await ensureMemryCalendarSource(db, client)
const sources = listCalendarSources(db, {
diff --git a/apps/desktop/src/main/ipc/calendar-handlers.test.ts b/apps/desktop/src/main/ipc/calendar-handlers.test.ts
index 2c8ce4020..4c7305ba3 100644
--- a/apps/desktop/src/main/ipc/calendar-handlers.test.ts
+++ b/apps/desktop/src/main/ipc/calendar-handlers.test.ts
@@ -15,6 +15,9 @@ const webContentsSend = vi.fn()
const mockConnectGoogleCalendar = vi.fn()
const mockDisconnectGoogleCalendar = vi.fn()
const mockHasGoogleCalendarLocalAuth = vi.fn()
+const mockHasAnyGoogleCalendarLocalAuth = vi.fn()
+const mockListGoogleAccountIds = vi.fn(() => [] as string[])
+const mockResolveDefaultGoogleAccountId = vi.fn(() => null as string | null)
const mockSyncGoogleCalendarNow = vi.fn()
const mockStartGoogleCalendarSyncRunner = vi.fn(async () => {})
const mockStopGoogleCalendarSyncRunner = vi.fn()
@@ -54,7 +57,10 @@ vi.mock('../sync/local-mutations', () => ({
vi.mock('../calendar/google/oauth', () => ({
connectGoogleCalendar: (...args: unknown[]) => mockConnectGoogleCalendar(...args),
disconnectGoogleCalendar: (...args: unknown[]) => mockDisconnectGoogleCalendar(...args),
- hasGoogleCalendarLocalAuth: (...args: unknown[]) => mockHasGoogleCalendarLocalAuth(...args)
+ hasGoogleCalendarLocalAuth: (...args: unknown[]) => mockHasGoogleCalendarLocalAuth(...args),
+ hasAnyGoogleCalendarLocalAuth: (...args: unknown[]) => mockHasAnyGoogleCalendarLocalAuth(...args),
+ listGoogleAccountIds: (...args: unknown[]) => mockListGoogleAccountIds(...args),
+ resolveDefaultGoogleAccountId: (...args: unknown[]) => mockResolveDefaultGoogleAccountId(...args)
}))
vi.mock('../calendar/google/sync-service', () => ({
@@ -89,6 +95,10 @@ describe('calendar-handlers', () => {
;(getDatabase as Mock).mockReturnValue(asClientDb(db))
;(requireDatabase as Mock).mockReturnValue(asClientDb(db))
mockHasGoogleCalendarLocalAuth.mockResolvedValue(false)
+ mockHasAnyGoogleCalendarLocalAuth.mockResolvedValue(false)
+ mockListGoogleAccountIds.mockReturnValue([])
+ mockResolveDefaultGoogleAccountId.mockReturnValue(null)
+ mockDisconnectGoogleCalendar.mockResolvedValue(undefined)
})
afterEach(() => {
@@ -440,8 +450,10 @@ describe('calendar-handlers', () => {
it('connects and disconnects the Google provider through the provider-specific auth module', async () => {
registerCalendarHandlers()
mockConnectGoogleCalendar.mockResolvedValue({
+ accountId: 'user@example.com',
account: {
remoteId: 'user@example.com',
+ email: 'user@example.com',
title: 'User Example',
timezone: 'Europe/Istanbul'
},
@@ -453,7 +465,8 @@ describe('calendar-handlers', () => {
isPrimary: true
}
})
- mockHasGoogleCalendarLocalAuth.mockResolvedValue(true)
+ mockHasAnyGoogleCalendarLocalAuth.mockResolvedValue(true)
+ mockListGoogleAccountIds.mockReturnValue(['user@example.com'])
const connect = await invokeHandler(CalendarChannels.invoke.CONNECT_PROVIDER, {
provider: 'google'
@@ -490,7 +503,7 @@ describe('calendar-handlers', () => {
})
])
- mockHasGoogleCalendarLocalAuth.mockResolvedValue(false)
+ mockHasAnyGoogleCalendarLocalAuth.mockResolvedValue(false)
const disconnect = await invokeHandler(CalendarChannels.invoke.DISCONNECT_PROVIDER, {
provider: 'google'
@@ -566,7 +579,7 @@ describe('calendar-handlers', () => {
it('refreshes Google provider state only when local auth exists', async () => {
registerCalendarHandlers()
- mockHasGoogleCalendarLocalAuth.mockResolvedValue(false)
+ mockHasAnyGoogleCalendarLocalAuth.mockResolvedValue(false)
const withoutAuth = await invokeHandler(CalendarChannels.invoke.REFRESH_PROVIDER, {
provider: 'google'
@@ -619,7 +632,7 @@ describe('calendar-handlers', () => {
)
`)
- mockHasGoogleCalendarLocalAuth.mockResolvedValue(true)
+ mockHasAnyGoogleCalendarLocalAuth.mockResolvedValue(true)
mockSyncGoogleCalendarNow.mockResolvedValue(undefined)
const refreshed = await invokeHandler(CalendarChannels.invoke.REFRESH_PROVIDER, {
diff --git a/apps/desktop/src/main/ipc/calendar-handlers.ts b/apps/desktop/src/main/ipc/calendar-handlers.ts
index 49f7f874e..a6cc89f6a 100644
--- a/apps/desktop/src/main/ipc/calendar-handlers.ts
+++ b/apps/desktop/src/main/ipc/calendar-handlers.ts
@@ -43,7 +43,9 @@ import {
import {
connectGoogleCalendar,
disconnectGoogleCalendar,
- hasGoogleCalendarLocalAuth
+ hasAnyGoogleCalendarLocalAuth,
+ listGoogleAccountIds,
+ resolveDefaultGoogleAccountId
} from '../calendar/google/oauth'
import { getCalendarRangeProjection } from '../calendar/projection'
import {
@@ -91,13 +93,11 @@ function mapCalendarEvent(row: typeof calendarEvents.$inferSelect): CalendarEven
isAllDay: row.isAllDay,
recurrenceRule: (row.recurrenceRule as Record | null) ?? null,
recurrenceExceptions: (row.recurrenceExceptions as string[] | null) ?? null,
- attendees:
- (row.attendees as CalendarEventRecord['attendees']) ?? null,
+ attendees: (row.attendees as CalendarEventRecord['attendees']) ?? null,
reminders: (row.reminders as CalendarEventRecord['reminders']) ?? null,
visibility: (row.visibility as CalendarEventRecord['visibility']) ?? null,
colorId: row.colorId ?? null,
- conferenceData:
- (row.conferenceData as CalendarEventRecord['conferenceData']) ?? null,
+ conferenceData: (row.conferenceData as CalendarEventRecord['conferenceData']) ?? null,
parentEventId: row.parentEventId ?? null,
originalStartTime: row.originalStartTime ?? null,
targetCalendarId: row.targetCalendarId ?? null,
@@ -140,7 +140,7 @@ async function buildProviderStatus(db: DataDb, provider: string): Promise source.lastSyncedAt ?? null)
].filter((value): value is string => Boolean(value))
- const hasLocalAuth = provider === 'google' ? await hasGoogleCalendarLocalAuth() : false
+ const hasLocalAuth = provider === 'google' ? await hasAnyGoogleCalendarLocalAuth(db) : false
return {
provider,
@@ -420,14 +420,14 @@ export function registerCalendarHandlers(): void {
}
const connected = await connectGoogleCalendar()
const now = new Date().toISOString()
- const accountSourceId = `google-account:${connected.account.remoteId}`
+ const accountSourceId = `google-account:${connected.accountId}`
const primaryCalendarSourceId = `google-calendar:${connected.primaryCalendar.remoteId}`
syncCalendarSourceUpsert(db, {
id: accountSourceId,
provider: 'google',
kind: 'account',
- accountId: null,
+ accountId: connected.accountId,
remoteId: connected.account.remoteId,
title: connected.account.title,
timezone: connected.account.timezone,
@@ -436,7 +436,7 @@ export function registerCalendarHandlers(): void {
isSelected: false,
isMemryManaged: false,
syncStatus: 'pending',
- metadata: { connectedVia: 'oauth' },
+ metadata: { connectedVia: 'oauth', email: connected.account.email },
createdAt: now,
modifiedAt: now
})
@@ -445,7 +445,7 @@ export function registerCalendarHandlers(): void {
id: primaryCalendarSourceId,
provider: 'google',
kind: 'calendar',
- accountId: accountSourceId,
+ accountId: connected.accountId,
remoteId: connected.primaryCalendar.remoteId,
title: connected.primaryCalendar.title,
timezone: connected.primaryCalendar.timezone,
@@ -485,7 +485,14 @@ export function registerCalendarHandlers(): void {
}
stopGoogleCalendarSyncRunner()
- await disconnectGoogleCalendar()
+ const accountIdsToDisconnect = listGoogleAccountIds(db)
+ for (const accountId of accountIdsToDisconnect) {
+ try {
+ await disconnectGoogleCalendar(accountId)
+ } catch (err) {
+ log.warn('Google Calendar disconnect failed', { accountId, err })
+ }
+ }
const providerSources = listCalendarSourceRows(db, { provider: input.provider })
const sourceIds = providerSources.map((source) => source.id)
@@ -577,7 +584,7 @@ export function registerCalendarHandlers(): void {
}
}
- if (!(await hasGoogleCalendarLocalAuth())) {
+ if (!(await hasAnyGoogleCalendarLocalAuth(db))) {
return {
success: false,
status: await buildProviderStatus(db, input.provider),
@@ -601,7 +608,11 @@ export function registerCalendarHandlers(): void {
createValidatedHandler(
ListGoogleCalendarsSchema,
withDb(async (db): Promise => {
- return await listGoogleCalendars(db, createGoogleCalendarClient())
+ const accountId = resolveDefaultGoogleAccountId(db)
+ if (!accountId) {
+ return { calendars: [], primary: null, currentDefaultId: null }
+ }
+ return await listGoogleCalendars(db, createGoogleCalendarClient({ accountId }))
}, 'Failed to list Google calendars')
)
)
diff --git a/apps/desktop/src/main/sync/session-teardown.ts b/apps/desktop/src/main/sync/session-teardown.ts
index 6bea078b5..b048e8f5b 100644
--- a/apps/desktop/src/main/sync/session-teardown.ts
+++ b/apps/desktop/src/main/sync/session-teardown.ts
@@ -14,7 +14,7 @@ import { clearInMemoryAuthState } from '../ipc/sync-core-handlers'
import { getDatabase, isDatabaseInitialized } from '../database/client'
import { store } from '../store'
import { createLogger } from '../lib/logger'
-import { disconnectGoogleCalendar } from '../calendar/google/oauth'
+import { disconnectGoogleCalendar, listGoogleAccountIds } from '../calendar/google/oauth'
import { stopGoogleCalendarSyncRunner } from '../calendar/google/sync-service'
const log = createLogger('SessionTeardown')
@@ -53,10 +53,16 @@ async function performTeardown(reason: TeardownReason): Promise
if (reason === 'logout') {
await revokeServerSession()
- try {
- await disconnectGoogleCalendar()
- } catch (err) {
- log.warn('Google Calendar disconnect failed during logout', err)
+ if (isDatabaseInitialized()) {
+ const db = getDatabase()
+ const accountIds = listGoogleAccountIds(db)
+ for (const accountId of accountIds) {
+ try {
+ await disconnectGoogleCalendar(accountId)
+ } catch (err) {
+ log.warn('Google Calendar disconnect failed during logout', { accountId, err })
+ }
+ }
}
}
From 8150e4f44a0c9ba27db669ff7ad4891f8d0ebb7c Mon Sep 17 00:00:00 2001
From: Kaan Karaca
Date: Sun, 19 Apr 2026 21:41:06 +0300
Subject: [PATCH 3/9] chore: absorb prettier reformats from prior session
Whitespace-only collapsing of multi-line array literals and JSX prop
spreads in M5 calendar files. No functional changes; formatter
re-applies them every save so landing them clears the working tree
ahead of M6 T3+.
---
.../src/main/calendar/promote-external-event.test.ts | 4 +---
.../repositories/calendar-events-repository.test.ts | 4 +---
.../item-handlers/calendar-external-event-handler.test.ts | 7 +------
.../components/calendar/calendar-event-metadata.test.tsx | 4 +---
.../src/components/calendar/calendar-event-metadata.tsx | 5 +----
.../renderer/src/components/calendar/calendar-shell.tsx | 5 +----
6 files changed, 6 insertions(+), 23 deletions(-)
diff --git a/apps/desktop/src/main/calendar/promote-external-event.test.ts b/apps/desktop/src/main/calendar/promote-external-event.test.ts
index 7617a8f1c..52342a5a4 100644
--- a/apps/desktop/src/main/calendar/promote-external-event.test.ts
+++ b/apps/desktop/src/main/calendar/promote-external-event.test.ts
@@ -169,9 +169,7 @@ describe('promoteExternalEvent (M2)', () => {
})
it('#given an external event carrying rich Google fields #when promoted #then carries attendees, reminders, visibility, colorId, conferenceData across to the local row (M5)', () => {
- const attendees = [
- { email: 'ceo@example.com', responseStatus: 'accepted', displayName: 'CEO' }
- ]
+ const attendees = [{ email: 'ceo@example.com', responseStatus: 'accepted', displayName: 'CEO' }]
const reminders = {
useDefault: false,
overrides: [{ method: 'popup' as const, minutes: 5 }]
diff --git a/apps/desktop/src/main/calendar/repositories/calendar-events-repository.test.ts b/apps/desktop/src/main/calendar/repositories/calendar-events-repository.test.ts
index 2adcde619..efbfe18f2 100644
--- a/apps/desktop/src/main/calendar/repositories/calendar-events-repository.test.ts
+++ b/apps/desktop/src/main/calendar/repositories/calendar-events-repository.test.ts
@@ -121,9 +121,7 @@ describe('calendarEvents rich fields (M5)', () => {
}
const conferenceData = {
conferenceId: 'abc-defg-hij',
- entryPoints: [
- { entryPointType: 'video', uri: 'https://meet.google.com/abc-defg-hij' }
- ]
+ entryPoints: [{ entryPointType: 'video', uri: 'https://meet.google.com/abc-defg-hij' }]
}
upsertCalendarEvent(dataDb, {
diff --git a/apps/desktop/src/main/sync/item-handlers/calendar-external-event-handler.test.ts b/apps/desktop/src/main/sync/item-handlers/calendar-external-event-handler.test.ts
index bee41647e..573df4453 100644
--- a/apps/desktop/src/main/sync/item-handlers/calendar-external-event-handler.test.ts
+++ b/apps/desktop/src/main/sync/item-handlers/calendar-external-event-handler.test.ts
@@ -133,12 +133,7 @@ describe('calendar external event handler — rich fields (M5 Codex P2c)', () =>
.run()
const freshCtx = makeCtx(freshDb)
- const applyResult = handler?.applyUpsert(
- freshCtx,
- 'external-rich',
- parsed,
- { 'device-a': 3 }
- )
+ const applyResult = handler?.applyUpsert(freshCtx, 'external-rich', parsed, { 'device-a': 3 })
expect(applyResult).toBe('applied')
const row = freshDb.db
diff --git a/apps/desktop/src/renderer/src/components/calendar/calendar-event-metadata.test.tsx b/apps/desktop/src/renderer/src/components/calendar/calendar-event-metadata.test.tsx
index 8da7b88fc..d025315fb 100644
--- a/apps/desktop/src/renderer/src/components/calendar/calendar-event-metadata.test.tsx
+++ b/apps/desktop/src/renderer/src/components/calendar/calendar-event-metadata.test.tsx
@@ -18,9 +18,7 @@ const FULL: CalendarEventMetadataProps = {
},
visibility: 'private',
conferenceData: {
- entryPoints: [
- { entryPointType: 'video', uri: 'https://meet.google.com/abc-defg-hij' }
- ]
+ entryPoints: [{ entryPointType: 'video', uri: 'https://meet.google.com/abc-defg-hij' }]
}
}
diff --git a/apps/desktop/src/renderer/src/components/calendar/calendar-event-metadata.tsx b/apps/desktop/src/renderer/src/components/calendar/calendar-event-metadata.tsx
index 423532cdb..06a9d7b50 100644
--- a/apps/desktop/src/renderer/src/components/calendar/calendar-event-metadata.tsx
+++ b/apps/desktop/src/renderer/src/components/calendar/calendar-event-metadata.tsx
@@ -92,10 +92,7 @@ export function CalendarEventMetadata(props: CalendarEventMetadataProps): React.
)}
{label}
diff --git a/apps/desktop/src/renderer/src/components/calendar/calendar-shell.tsx b/apps/desktop/src/renderer/src/components/calendar/calendar-shell.tsx
index 98df25c66..fb1fbfa31 100644
--- a/apps/desktop/src/renderer/src/components/calendar/calendar-shell.tsx
+++ b/apps/desktop/src/renderer/src/components/calendar/calendar-shell.tsx
@@ -2,10 +2,7 @@ import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { SlidersHorizontal } from '@/lib/icons'
import { CalendarDayView } from './calendar-day-view'
-import {
- CalendarEventPopover,
- type CalendarEventReadOnlyMetadata
-} from './calendar-event-popover'
+import { CalendarEventPopover, type CalendarEventReadOnlyMetadata } from './calendar-event-popover'
import { CalendarMonthView } from './calendar-month-view'
import { CalendarToolbar, type CalendarWorkspaceView } from './calendar-toolbar'
import { CalendarWeekView } from './calendar-week-view'
From 1a4fef038d28e4ff20bf9c6aa0eaa8986dbaeab0 Mon Sep 17 00:00:00 2001
From: Kaan Karaca
Date: Sun, 19 Apr 2026 21:41:32 +0300
Subject: [PATCH 4/9] feat(calendar): provider status IPC returns per-account
array (M6 T3)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds `CalendarProviderAccountStatus` to the calendar contract and an
`accounts: CalendarProviderAccountStatus[]` field on
`CalendarProviderStatus`. Each entry surfaces:
{ accountId, email, status, lastSyncedAt, lastError }
`status` collapses the underlying `calendar_sources.syncStatus` enum
into the renderer-friendly `'connected' | 'disconnected' | 'error'`
projection: any account whose keychain still has tokens and whose
source row isn't in `'error'` reads as `'connected'`; rows in `'error'`
expose the latest message via `lastError`; rows whose tokens have been
revoked at the OS keychain (e.g. user-revoked from Google) downgrade
to `'disconnected'` even if the DB still has the source row. `email`
falls back to `source.title` when the metadata column is missing.
The legacy single `account` field stays on the contract so the
existing renderer keeps working until the rest of M6 lands; new code
should prefer `accounts`.
Renderer:
- `GoogleCalendarIntegrationRow` renders a chip per account using the
array — colour-coded per status, last-error text truncated at 60
chars, full message in the chip's `title` tooltip. Each chip has
`data-testid` + `data-account-status` so tests + future health
surfaces can target them deterministically.
Tests:
- `calendar-handlers.test.ts`: new "returns one account in
status.accounts per connected Google account" inserts two account
rows (one with `syncStatus='ok'`, one with `syncStatus='error'`)
and stubs per-account keychain lookups to confirm the
`connected | disconnected | error` projection. Existing fixtures
pick up `accounts: []` / `accounts: [...]`.
- `google-calendar-integration-row.test.tsx`: new "renders one chip
per connected Google account" asserts both emails render and chip
attributes match (status='connected' for Alice, status='error'
with lastError text for Bob).
Closes G10's IPC half; renderer health surface (sync-health widget,
retry button) lands in M6 T6.
---
.../src/main/ipc/calendar-handlers.test.ts | 69 +++++++++++++++++++
.../desktop/src/main/ipc/calendar-handlers.ts | 43 +++++++++++-
.../google-calendar-integration-row.test.tsx | 51 ++++++++++++++
.../google-calendar-integration-row.tsx | 29 +++++++-
packages/contracts/src/calendar-api.ts | 11 +++
5 files changed, 199 insertions(+), 4 deletions(-)
diff --git a/apps/desktop/src/main/ipc/calendar-handlers.test.ts b/apps/desktop/src/main/ipc/calendar-handlers.test.ts
index 4c7305ba3..e26b30da4 100644
--- a/apps/desktop/src/main/ipc/calendar-handlers.test.ts
+++ b/apps/desktop/src/main/ipc/calendar-handlers.test.ts
@@ -438,6 +438,7 @@ describe('calendar-handlers', () => {
id: 'google-account-1',
title: 'h4yfans@gmail.com'
}),
+ accounts: [],
calendars: {
total: 1,
selected: 1,
@@ -481,6 +482,12 @@ describe('calendar-handlers', () => {
id: 'google-account:user@example.com',
title: 'User Example'
},
+ accounts: expect.arrayContaining([
+ expect.objectContaining({
+ accountId: 'user@example.com',
+ email: 'user@example.com'
+ })
+ ]),
calendars: {
total: 1,
selected: 1,
@@ -515,6 +522,7 @@ describe('calendar-handlers', () => {
connected: false,
hasLocalAuth: false,
account: null,
+ accounts: [],
calendars: {
total: 0,
selected: 0,
@@ -577,6 +585,65 @@ describe('calendar-handlers', () => {
})
})
+ it('returns one account in status.accounts per connected Google account (M6 T3)', async () => {
+ registerCalendarHandlers()
+
+ db.run(sql`
+ INSERT INTO calendar_sources (
+ id, provider, kind, account_id, remote_id, title, timezone,
+ is_selected, sync_status, last_synced_at, metadata, created_at, modified_at
+ ) VALUES (
+ ${'google-account:alice@example.com'}, ${'google'}, ${'account'},
+ ${'alice@example.com'}, ${'alice@example.com'}, ${'Alice'}, ${'UTC'},
+ ${0}, ${'ok'}, ${'2026-04-15T10:00:00.000Z'},
+ ${JSON.stringify({ email: 'alice@example.com' })},
+ ${'2026-04-15T10:00:00.000Z'}, ${'2026-04-15T10:00:00.000Z'}
+ )
+ `)
+ db.run(sql`
+ INSERT INTO calendar_sources (
+ id, provider, kind, account_id, remote_id, title, timezone,
+ is_selected, sync_status, last_synced_at, metadata, created_at, modified_at
+ ) VALUES (
+ ${'google-account:bob@example.com'}, ${'google'}, ${'account'},
+ ${'bob@example.com'}, ${'bob@example.com'}, ${'Bob'}, ${'UTC'},
+ ${0}, ${'error'}, ${'2026-04-15T09:00:00.000Z'},
+ ${JSON.stringify({ email: 'bob@example.com', lastError: 'token revoked by Google' })},
+ ${'2026-04-15T09:00:00.000Z'}, ${'2026-04-15T09:00:00.000Z'}
+ )
+ `)
+
+ mockHasAnyGoogleCalendarLocalAuth.mockResolvedValue(true)
+ mockHasGoogleCalendarLocalAuth.mockImplementation(async (accountId: string) => {
+ // Bob's keychain entry was wiped (e.g. token revoked); Alice still has tokens.
+ return accountId === 'alice@example.com'
+ })
+
+ const status = await invokeHandler(CalendarChannels.invoke.GET_PROVIDER_STATUS, {
+ provider: 'google'
+ })
+
+ expect(status.accounts).toHaveLength(2)
+ expect(status.accounts).toEqual(
+ expect.arrayContaining([
+ {
+ accountId: 'alice@example.com',
+ email: 'alice@example.com',
+ status: 'connected',
+ lastSyncedAt: '2026-04-15T10:00:00.000Z',
+ lastError: null
+ },
+ {
+ accountId: 'bob@example.com',
+ email: 'bob@example.com',
+ status: 'disconnected',
+ lastSyncedAt: '2026-04-15T09:00:00.000Z',
+ lastError: 'token revoked by Google'
+ }
+ ])
+ )
+ })
+
it('refreshes Google provider state only when local auth exists', async () => {
registerCalendarHandlers()
mockHasAnyGoogleCalendarLocalAuth.mockResolvedValue(false)
@@ -592,6 +659,7 @@ describe('calendar-handlers', () => {
connected: false,
hasLocalAuth: false,
account: null,
+ accounts: [],
calendars: {
total: 0,
selected: 0,
@@ -649,6 +717,7 @@ describe('calendar-handlers', () => {
id: 'google-account-1',
title: 'User Example'
},
+ accounts: [],
calendars: {
total: 0,
selected: 0,
diff --git a/apps/desktop/src/main/ipc/calendar-handlers.ts b/apps/desktop/src/main/ipc/calendar-handlers.ts
index a6cc89f6a..9f00d2dc4 100644
--- a/apps/desktop/src/main/ipc/calendar-handlers.ts
+++ b/apps/desktop/src/main/ipc/calendar-handlers.ts
@@ -17,6 +17,8 @@ import {
type CalendarEventListResponse,
type CalendarEventMutationResponse,
type CalendarEventRecord,
+ type CalendarProviderAccountConnectionStatus,
+ type CalendarProviderAccountStatus,
type CalendarProviderMutationResponse,
type CalendarProviderStatus,
type CalendarRangeResponse,
@@ -44,6 +46,7 @@ import {
connectGoogleCalendar,
disconnectGoogleCalendar,
hasAnyGoogleCalendarLocalAuth,
+ hasGoogleCalendarLocalAuth,
listGoogleAccountIds,
resolveDefaultGoogleAccountId
} from '../calendar/google/oauth'
@@ -132,21 +135,57 @@ function mapCalendarSource(row: typeof calendarSources.$inferSelect): CalendarSo
}
}
+async function buildProviderAccountStatus(
+ source: typeof calendarSources.$inferSelect
+): Promise {
+ const accountId = source.accountId
+ if (!accountId) return null
+
+ const metadata = (source.metadata as { email?: string; lastError?: string } | null) ?? null
+ const hasLocalAuth =
+ source.provider === 'google' ? await hasGoogleCalendarLocalAuth(accountId) : false
+
+ let status: CalendarProviderAccountConnectionStatus
+ if (!hasLocalAuth) {
+ status = 'disconnected'
+ } else if (source.syncStatus === 'error') {
+ status = 'error'
+ } else {
+ status = 'connected'
+ }
+
+ return {
+ accountId,
+ email: metadata?.email ?? source.title,
+ status,
+ lastSyncedAt: source.lastSyncedAt ?? null,
+ lastError: metadata?.lastError ?? null
+ }
+}
+
async function buildProviderStatus(db: DataDb, provider: string): Promise {
const allSources = listCalendarSourceRows(db, { provider })
- const account = allSources.find((source) => source.kind === 'account') ?? null
+ const accountSources = allSources.filter((source) => source.kind === 'account')
+ const account = accountSources[0] ?? null
const calendars = allSources.filter((source) => source.kind === 'calendar')
const syncedCandidates = [
- account?.lastSyncedAt ?? null,
+ ...accountSources.map((source) => source.lastSyncedAt ?? null),
...calendars.map((source) => source.lastSyncedAt ?? null)
].filter((value): value is string => Boolean(value))
const hasLocalAuth = provider === 'google' ? await hasAnyGoogleCalendarLocalAuth(db) : false
+ const accounts: CalendarProviderAccountStatus[] = []
+ for (const source of accountSources) {
+ const accountStatus = await buildProviderAccountStatus(source)
+ if (accountStatus) accounts.push(accountStatus)
+ }
+
return {
provider,
connected: Boolean(account),
hasLocalAuth,
account: account ? { id: account.id, title: account.title } : null,
+ accounts,
calendars: {
total: calendars.length,
selected: calendars.filter((source) => source.isSelected).length,
diff --git a/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.test.tsx b/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.test.tsx
index 3e9fc5f01..35a6bfc64 100644
--- a/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.test.tsx
+++ b/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.test.tsx
@@ -45,6 +45,7 @@ const DISCONNECTED_STATUS: CalendarProviderStatus = {
connected: false,
hasLocalAuth: false,
account: null,
+ accounts: [],
calendars: {
total: 0,
selected: 0,
@@ -58,6 +59,15 @@ const CONNECTED_STATUS: CalendarProviderStatus = {
connected: true,
hasLocalAuth: true,
account: { id: 'google-account-1', title: 'h4yfans@gmail.com' },
+ accounts: [
+ {
+ accountId: 'h4yfans@gmail.com',
+ email: 'h4yfans@gmail.com',
+ status: 'connected',
+ lastSyncedAt: '2026-04-12T08:00:00.000Z',
+ lastError: null
+ }
+ ],
calendars: {
total: 3,
selected: 2,
@@ -66,6 +76,26 @@ const CONNECTED_STATUS: CalendarProviderStatus = {
lastSyncedAt: '2026-04-12T08:00:00.000Z'
}
+const TWO_ACCOUNT_STATUS: CalendarProviderStatus = {
+ ...CONNECTED_STATUS,
+ accounts: [
+ {
+ accountId: 'alice@example.com',
+ email: 'alice@example.com',
+ status: 'connected',
+ lastSyncedAt: '2026-04-12T08:00:00.000Z',
+ lastError: null
+ },
+ {
+ accountId: 'bob@example.com',
+ email: 'bob@example.com',
+ status: 'error',
+ lastSyncedAt: '2026-04-11T22:30:00.000Z',
+ lastError: 'token revoked by Google'
+ }
+ ]
+}
+
const CONNECTED_SOURCES: CalendarSourceRecord[] = [
{
id: 'google-account-1',
@@ -228,6 +258,27 @@ describe('Google Calendar integration row', () => {
})
})
+ it('renders one chip per connected Google account with status + email (M6 T3)', async () => {
+ mockGetGoogleCalendarStatus.mockResolvedValue(TWO_ACCOUNT_STATUS)
+ mockListSources.mockResolvedValue({ sources: CONNECTED_SOURCES })
+ vi.mocked(window.api.settings.getCalendarGoogleSettings).mockResolvedValue({
+ defaultTargetCalendarId: 'primary@example.com',
+ onboardingCompleted: true,
+ promoteConfirmDismissed: false
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => expect(screen.getByText('alice@example.com')).toBeInTheDocument())
+ expect(screen.getByText('bob@example.com')).toBeInTheDocument()
+
+ const aliceChip = screen.getByTestId('calendar-account-chip-alice@example.com')
+ const bobChip = screen.getByTestId('calendar-account-chip-bob@example.com')
+ expect(aliceChip).toHaveAttribute('data-account-status', 'connected')
+ expect(bobChip).toHaveAttribute('data-account-status', 'error')
+ expect(bobChip).toHaveTextContent('token revoked by Google')
+ })
+
it('#given an existing Google connection + onboardingCompleted=true #when the row mounts #then the onboarding dialog stays closed', async () => {
mockGetGoogleCalendarStatus.mockResolvedValue(CONNECTED_STATUS)
mockListSources.mockResolvedValue({ sources: CONNECTED_SOURCES })
diff --git a/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.tsx b/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.tsx
index 1b01636a3..330bee892 100644
--- a/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.tsx
+++ b/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.tsx
@@ -159,8 +159,33 @@ export function GoogleCalendarIntegrationRow(): React.JSX.Element {
Two-way sync for Memry events and imported Google calendars.
- {status?.account && (
- Connected as {status.account.title}
+ {status?.accounts && status.accounts.length > 0 && (
+
+ {status.accounts.map((account) => {
+ const tone =
+ account.status === 'connected'
+ ? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
+ : account.status === 'error'
+ ? 'border-destructive/50 bg-destructive/10 text-destructive'
+ : 'border-muted-foreground/30 bg-muted text-muted-foreground'
+ return (
+
+ {account.email}
+ {account.status === 'error' && account.lastError && (
+
+ · {account.lastError.slice(0, 60)}
+
+ )}
+
+ )
+ })}
+
)}
{mutationError && (
diff --git a/packages/contracts/src/calendar-api.ts b/packages/contracts/src/calendar-api.ts
index 8aa5e473e..f8d4016d2 100644
--- a/packages/contracts/src/calendar-api.ts
+++ b/packages/contracts/src/calendar-api.ts
@@ -231,11 +231,22 @@ export interface CalendarProjectionItem {
binding: CalendarProjectionBinding | null
}
+export type CalendarProviderAccountConnectionStatus = 'connected' | 'disconnected' | 'error'
+
+export interface CalendarProviderAccountStatus {
+ accountId: string
+ email: string
+ status: CalendarProviderAccountConnectionStatus
+ lastSyncedAt: string | null
+ lastError: string | null
+}
+
export interface CalendarProviderStatus {
provider: string
connected: boolean
hasLocalAuth: boolean
account: Pick | null
+ accounts: CalendarProviderAccountStatus[]
calendars: {
total: number
selected: number
From 8a3a6935f3077e2dc827f54d1ba089136c6f6119 Mon Sep 17 00:00:00 2001
From: Kaan Karaca
Date: Sun, 19 Apr 2026 21:47:31 +0300
Subject: [PATCH 5/9] feat(calendar): route Google push by target calendar's
account (M6 T4)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Pushes that previously always used "the one default account's" client
now resolve the right account from the target calendar's source row.
The new helper `resolveTargetGoogleAccountId(db, target,
existingBinding)` walks:
1. existingBinding.remoteCalendarId → calendar source's accountId
2. for events: calendarEvents.targetCalendarId → calendar source's
accountId
3. fallback: resolveDefaultGoogleAccountId(db)
`pushSourceToGoogleCalendar` and `deleteSourceFromGoogleCalendar` now
ask the helper before constructing the client. `getGoogleClient`
takes an optional `accountIdOverride` so the routing decision flows
all the way to keychain — events bound to account A's calendar push
through account A's tokens; events bound to account B's calendar
push through account B's. Tests with `deps.client` short-circuit the
factory and stay routing-agnostic, so existing M2/M3/M5 sync-service
test scenarios keep working unchanged after extending their oauth
mock.
Tests:
- New `account-routing.test.ts` covers all four resolution branches:
binding-priority, event.targetCalendarId, default fallback, and the
explicit "no account anywhere" null case.
- `sync-service.test.ts` oauth mock now exposes
`resolveDefaultGoogleAccountId`, `hasAnyGoogleCalendarLocalAuth`,
and `listGoogleAccountIds` so the routing helper has an answer in
unit-test fixtures.
Closes the routing half of G9. Per-account scoped disconnect
(M6 T5), sync-health UI (M6 T6) still pending.
---
.../calendar/google/account-routing.test.ts | 157 ++++++++++++++++++
.../main/calendar/google/account-routing.ts | 53 ++++++
.../main/calendar/google/sync-service.test.ts | 5 +-
.../src/main/calendar/google/sync-service.ts | 12 +-
4 files changed, 222 insertions(+), 5 deletions(-)
create mode 100644 apps/desktop/src/main/calendar/google/account-routing.test.ts
create mode 100644 apps/desktop/src/main/calendar/google/account-routing.ts
diff --git a/apps/desktop/src/main/calendar/google/account-routing.test.ts b/apps/desktop/src/main/calendar/google/account-routing.test.ts
new file mode 100644
index 000000000..cdad85098
--- /dev/null
+++ b/apps/desktop/src/main/calendar/google/account-routing.test.ts
@@ -0,0 +1,157 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { calendarBindings } from '@memry/db-schema/schema/calendar-bindings'
+import { calendarEvents } from '@memry/db-schema/schema/calendar-events'
+import { calendarSources } from '@memry/db-schema/schema/calendar-sources'
+import { createTestDataDb, type TestDatabaseResult, type TestDb } from '@tests/utils/test-db'
+
+vi.mock('./oauth', () => ({
+ resolveDefaultGoogleAccountId: vi.fn((_db: unknown) => 'fallback-default@example.com')
+}))
+
+import { resolveTargetGoogleAccountId } from './account-routing'
+
+const NOW = '2026-04-19T10:00:00.000Z'
+
+function seedAccountAndCalendar(
+ db: TestDb,
+ accountId: string,
+ calendarRemoteId: string,
+ calendarSourceId: string
+): void {
+ db.insert(calendarSources)
+ .values({
+ id: `google-account:${accountId}`,
+ provider: 'google',
+ kind: 'account',
+ accountId,
+ remoteId: accountId,
+ title: accountId,
+ timezone: 'UTC',
+ color: null,
+ isPrimary: false,
+ isSelected: false,
+ isMemryManaged: false,
+ syncStatus: 'ok',
+ metadata: null,
+ createdAt: NOW,
+ modifiedAt: NOW
+ })
+ .run()
+ db.insert(calendarSources)
+ .values({
+ id: calendarSourceId,
+ provider: 'google',
+ kind: 'calendar',
+ accountId,
+ remoteId: calendarRemoteId,
+ title: `${accountId}/${calendarRemoteId}`,
+ timezone: 'UTC',
+ color: null,
+ isPrimary: false,
+ isSelected: true,
+ isMemryManaged: false,
+ syncStatus: 'ok',
+ metadata: null,
+ createdAt: NOW,
+ modifiedAt: NOW
+ })
+ .run()
+}
+
+describe('resolveTargetGoogleAccountId (M6 T4)', () => {
+ let dbResult: TestDatabaseResult
+ let db: TestDb
+
+ beforeEach(() => {
+ dbResult = createTestDataDb()
+ db = dbResult.db
+ })
+
+ afterEach(() => {
+ dbResult.close()
+ })
+
+ it('routes through existingBinding.remoteCalendarId when present', () => {
+ // #given two account+calendar pairs; binding points at account B's calendar
+ seedAccountAndCalendar(db, 'alice@example.com', 'cal-A', 'gcal:A')
+ seedAccountAndCalendar(db, 'bob@example.com', 'cal-B', 'gcal:B')
+ const binding: typeof calendarBindings.$inferSelect = {
+ id: 'binding-1',
+ sourceType: 'event',
+ sourceId: 'evt-1',
+ provider: 'google',
+ remoteCalendarId: 'cal-B',
+ remoteEventId: 'remote-evt-1',
+ ownershipMode: 'memry_managed',
+ writebackMode: 'broad',
+ remoteVersion: null,
+ lastLocalSnapshot: null,
+ archivedAt: null,
+ clock: null,
+ syncedAt: null,
+ createdAt: NOW,
+ modifiedAt: NOW
+ }
+
+ // #when
+ const accountId = resolveTargetGoogleAccountId(
+ db,
+ { sourceType: 'event', sourceId: 'evt-1' },
+ binding
+ )
+
+ // #then
+ expect(accountId).toBe('bob@example.com')
+ })
+
+ it('routes through event.targetCalendarId when no existing binding', () => {
+ seedAccountAndCalendar(db, 'alice@example.com', 'cal-A', 'gcal:A')
+ seedAccountAndCalendar(db, 'bob@example.com', 'cal-B', 'gcal:B')
+ db.insert(calendarEvents)
+ .values({
+ id: 'evt-2',
+ title: 'Routed event',
+ startAt: NOW,
+ endAt: null,
+ timezone: 'UTC',
+ isAllDay: false,
+ targetCalendarId: 'cal-A',
+ createdAt: NOW,
+ modifiedAt: NOW
+ })
+ .run()
+
+ const accountId = resolveTargetGoogleAccountId(
+ db,
+ { sourceType: 'event', sourceId: 'evt-2' },
+ undefined
+ )
+
+ expect(accountId).toBe('alice@example.com')
+ })
+
+ it('falls back to default Google account when no calendar context resolves', () => {
+ seedAccountAndCalendar(db, 'alice@example.com', 'cal-A', 'gcal:A')
+
+ const accountId = resolveTargetGoogleAccountId(
+ db,
+ { sourceType: 'task', sourceId: 'task-1' },
+ undefined
+ )
+
+ expect(accountId).toBe('fallback-default@example.com')
+ })
+
+ it('returns null when neither calendar context nor any default account exists', async () => {
+ const oauth = await import('./oauth')
+ vi.mocked(oauth.resolveDefaultGoogleAccountId).mockReturnValueOnce(null)
+
+ const accountId = resolveTargetGoogleAccountId(
+ db,
+ { sourceType: 'task', sourceId: 'task-1' },
+ undefined
+ )
+
+ expect(accountId).toBeNull()
+ })
+})
diff --git a/apps/desktop/src/main/calendar/google/account-routing.ts b/apps/desktop/src/main/calendar/google/account-routing.ts
new file mode 100644
index 000000000..3d58ccc82
--- /dev/null
+++ b/apps/desktop/src/main/calendar/google/account-routing.ts
@@ -0,0 +1,53 @@
+import { and, eq, isNull } from 'drizzle-orm'
+import { calendarEvents } from '@memry/db-schema/schema/calendar-events'
+import { calendarSources } from '@memry/db-schema/schema/calendar-sources'
+import type { calendarBindings } from '@memry/db-schema/schema/calendar-bindings'
+import type { DataDb } from '../../database/types'
+import type { CalendarSyncTarget } from '../types'
+import { resolveDefaultGoogleAccountId } from './oauth'
+
+function findAccountIdForCalendarRemoteId(db: DataDb, remoteCalendarId: string): string | null {
+ const row = db
+ .select({ accountId: calendarSources.accountId })
+ .from(calendarSources)
+ .where(
+ and(
+ eq(calendarSources.provider, 'google'),
+ eq(calendarSources.kind, 'calendar'),
+ eq(calendarSources.remoteId, remoteCalendarId),
+ isNull(calendarSources.archivedAt)
+ )
+ )
+ .get()
+ return row?.accountId ?? null
+}
+
+function findEventTargetCalendarId(db: DataDb, eventId: string): string | null {
+ const row = db
+ .select({ targetCalendarId: calendarEvents.targetCalendarId })
+ .from(calendarEvents)
+ .where(eq(calendarEvents.id, eventId))
+ .get()
+ return row?.targetCalendarId ?? null
+}
+
+export function resolveTargetGoogleAccountId(
+ db: DataDb,
+ target: CalendarSyncTarget,
+ existingBinding: typeof calendarBindings.$inferSelect | undefined
+): string | null {
+ if (existingBinding?.remoteCalendarId) {
+ const accountId = findAccountIdForCalendarRemoteId(db, existingBinding.remoteCalendarId)
+ if (accountId) return accountId
+ }
+
+ if (target.sourceType === 'event') {
+ const targetCalendarId = findEventTargetCalendarId(db, target.sourceId)
+ if (targetCalendarId) {
+ const accountId = findAccountIdForCalendarRemoteId(db, targetCalendarId)
+ if (accountId) return accountId
+ }
+ }
+
+ return resolveDefaultGoogleAccountId(db)
+}
diff --git a/apps/desktop/src/main/calendar/google/sync-service.test.ts b/apps/desktop/src/main/calendar/google/sync-service.test.ts
index 5a2f161a7..a2f47ff1a 100644
--- a/apps/desktop/src/main/calendar/google/sync-service.test.ts
+++ b/apps/desktop/src/main/calendar/google/sync-service.test.ts
@@ -34,7 +34,10 @@ vi.mock('electron', () => ({
vi.mock('./oauth', () => ({
hasGoogleCalendarLocalAuth: vi.fn(async () => true),
- hasGoogleCalendarConnection: vi.fn(async () => true)
+ hasGoogleCalendarConnection: vi.fn(async () => true),
+ hasAnyGoogleCalendarLocalAuth: vi.fn(async () => true),
+ resolveDefaultGoogleAccountId: vi.fn(() => 'test-account@example.com'),
+ listGoogleAccountIds: vi.fn(() => ['test-account@example.com'])
}))
vi.mock('../../sync/auth-state', () => ({
diff --git a/apps/desktop/src/main/calendar/google/sync-service.ts b/apps/desktop/src/main/calendar/google/sync-service.ts
index 48954ab61..4061356c6 100644
--- a/apps/desktop/src/main/calendar/google/sync-service.ts
+++ b/apps/desktop/src/main/calendar/google/sync-service.ts
@@ -17,6 +17,7 @@ import { CALENDAR_EVENT_SYNCABLE_FIELDS, mergeCalendarEventFields } from '../fie
import type { FieldClocks, VectorClock } from '@memry/contracts/sync-api'
import { publishProjectionEvent } from '../../projections'
import { hasGoogleCalendarConnection, resolveDefaultGoogleAccountId } from './oauth'
+import { resolveTargetGoogleAccountId } from './account-routing'
import { isMemryUserSignedIn } from '../../sync/auth-state'
import { createGoogleCalendarClient } from './client'
import {
@@ -372,10 +373,11 @@ export async function ensureGoogleCalendarSourceSelected(
function getGoogleClient(
db: DataDb,
- deps?: { client?: GoogleCalendarClient }
+ deps?: { client?: GoogleCalendarClient },
+ accountIdOverride?: string | null
): GoogleCalendarClient {
if (deps?.client) return deps.client
- const accountId = resolveDefaultGoogleAccountId(db)
+ const accountId = accountIdOverride ?? resolveDefaultGoogleAccountId(db)
if (!accountId) {
throw new Error('Cannot create Google Calendar client without a connected account')
}
@@ -524,8 +526,9 @@ export async function pushSourceToGoogleCalendar(
>
} = {}
): Promise {
- const client = getGoogleClient(db, deps as { client?: GoogleCalendarClient })
const existingBinding = getExistingGoogleBinding(db, target)
+ const routedAccountId = resolveTargetGoogleAccountId(db, target, existingBinding)
+ const client = getGoogleClient(db, deps as { client?: GoogleCalendarClient }, routedAccountId)
const resolvedCalendarId = await resolveTargetCalendarId(db, target, existingBinding, client)
const now = getNow()
const bindingId =
@@ -574,7 +577,8 @@ export async function deleteSourceFromGoogleCalendar(
return false
}
- const client = getGoogleClient(db, deps as { client?: GoogleCalendarClient })
+ const routedAccountId = resolveTargetGoogleAccountId(db, target, existingBinding)
+ const client = getGoogleClient(db, deps as { client?: GoogleCalendarClient }, routedAccountId)
await client.deleteEvent({
calendarId: existingBinding.remoteCalendarId,
eventId: existingBinding.remoteEventId
From 56454c07b7a24a0b3abc2ac7205d3e584a05f61c Mon Sep 17 00:00:00 2001
From: Kaan Karaca
Date: Sun, 19 Apr 2026 21:51:15 +0300
Subject: [PATCH 6/9] feat(calendar): scope DISCONNECT_PROVIDER to a single
account (M6 T5)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds an optional `accountId` field to `CalendarProviderRequestSchema`.
When the renderer (M6 T6 will use this from the per-account chip's
overflow menu) supplies it, only that account is disconnected:
- the per-account keychain entries (revoke + clear) via the now
per-account `disconnectGoogleCalendar(accountId)`
- only that account's `kind='calendar'` rows + the `kind='account'`
parent are tombstoned (set `archivedAt`); we use update-with-
`syncCalendarSourceUpdate` instead of delete so the row's clock +
E2EE sync envelope flow the archive to other devices
- only push channels whose parent calendar source belongs to the
removed account are stopped via
`pushRuntime.handleSelectionToggle({…, isSelected: false})`. Other
accounts' channels stay live.
- bindings + external-event mirror rows scoped by `remoteCalendarId`
to the removed account's calendars are removed (hard-delete is
fine here — the deleted-row tombstone already syncs)
- the global sync runner is **not** stopped (other accounts still
need it)
Without `accountId`, the legacy nuke-all path keeps its prior
semantics so the existing big "Disconnect" button remains a
no-confirmation provider-wide reset.
Tests:
- new "disconnects only the requested accountId, leaving other
accounts intact" seeds Alice+Bob accounts each with one calendar,
fires `DISCONNECT_PROVIDER` with `accountId: alice@example.com`,
asserts (a) `disconnectGoogleCalendar` was called exactly once
with Alice's id, (b) Alice's source rows disappear from
`LIST_SOURCES` (filtered by `archivedAt`), and (c) Bob's account +
calendar rows are still live.
---
.../src/main/ipc/calendar-handlers.test.ts | 51 ++++++++
.../desktop/src/main/ipc/calendar-handlers.ts | 122 ++++++++++++++++++
packages/contracts/src/calendar-api.ts | 3 +-
3 files changed, 175 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/main/ipc/calendar-handlers.test.ts b/apps/desktop/src/main/ipc/calendar-handlers.test.ts
index e26b30da4..27db8c8dc 100644
--- a/apps/desktop/src/main/ipc/calendar-handlers.test.ts
+++ b/apps/desktop/src/main/ipc/calendar-handlers.test.ts
@@ -644,6 +644,57 @@ describe('calendar-handlers', () => {
)
})
+ it('disconnects only the requested accountId, leaving other accounts intact (M6 T5)', async () => {
+ registerCalendarHandlers()
+
+ db.run(sql`
+ INSERT INTO calendar_sources (
+ id, provider, kind, account_id, remote_id, title, timezone,
+ is_selected, sync_status, metadata, created_at, modified_at
+ ) VALUES
+ (${'google-account:alice@example.com'}, ${'google'}, ${'account'},
+ ${'alice@example.com'}, ${'alice@example.com'}, ${'Alice'}, ${'UTC'},
+ ${0}, ${'ok'}, ${JSON.stringify({ email: 'alice@example.com' })},
+ ${'2026-04-15T10:00:00.000Z'}, ${'2026-04-15T10:00:00.000Z'}),
+ (${'google-calendar:alice-primary'}, ${'google'}, ${'calendar'},
+ ${'alice@example.com'}, ${'alice@cal'}, ${'Alice Cal'}, ${'UTC'},
+ ${1}, ${'ok'}, ${null},
+ ${'2026-04-15T10:00:00.000Z'}, ${'2026-04-15T10:00:00.000Z'}),
+ (${'google-account:bob@example.com'}, ${'google'}, ${'account'},
+ ${'bob@example.com'}, ${'bob@example.com'}, ${'Bob'}, ${'UTC'},
+ ${0}, ${'ok'}, ${JSON.stringify({ email: 'bob@example.com' })},
+ ${'2026-04-15T10:00:00.000Z'}, ${'2026-04-15T10:00:00.000Z'}),
+ (${'google-calendar:bob-primary'}, ${'google'}, ${'calendar'},
+ ${'bob@example.com'}, ${'bob@cal'}, ${'Bob Cal'}, ${'UTC'},
+ ${1}, ${'ok'}, ${null},
+ ${'2026-04-15T10:00:00.000Z'}, ${'2026-04-15T10:00:00.000Z'})
+ `)
+
+ mockHasAnyGoogleCalendarLocalAuth.mockResolvedValue(true)
+ mockHasGoogleCalendarLocalAuth.mockResolvedValue(true)
+ mockListGoogleAccountIds.mockReturnValue(['alice@example.com', 'bob@example.com'])
+
+ const result = await invokeHandler(CalendarChannels.invoke.DISCONNECT_PROVIDER, {
+ provider: 'google',
+ accountId: 'alice@example.com'
+ })
+
+ expect(result.success).toBe(true)
+ expect(mockDisconnectGoogleCalendar).toHaveBeenCalledTimes(1)
+ expect(mockDisconnectGoogleCalendar).toHaveBeenCalledWith('alice@example.com')
+
+ const sources = await invokeHandler(CalendarChannels.invoke.LIST_SOURCES, {
+ provider: 'google'
+ })
+ const sourceIds = sources.sources.map((s: { id: string }) => s.id)
+ // Alice's rows tombstoned (filtered out by listCalendarSources via archivedAt);
+ // Bob's rows still active.
+ expect(sourceIds).not.toContain('google-account:alice@example.com')
+ expect(sourceIds).not.toContain('google-calendar:alice-primary')
+ expect(sourceIds).toContain('google-account:bob@example.com')
+ expect(sourceIds).toContain('google-calendar:bob-primary')
+ })
+
it('refreshes Google provider state only when local auth exists', async () => {
registerCalendarHandlers()
mockHasAnyGoogleCalendarLocalAuth.mockResolvedValue(false)
diff --git a/apps/desktop/src/main/ipc/calendar-handlers.ts b/apps/desktop/src/main/ipc/calendar-handlers.ts
index 9f00d2dc4..53d8237b2 100644
--- a/apps/desktop/src/main/ipc/calendar-handlers.ts
+++ b/apps/desktop/src/main/ipc/calendar-handlers.ts
@@ -224,6 +224,124 @@ function syncCalendarSourceUpsert(
return mapCalendarSource(saved)
}
+async function disconnectGoogleAccount(
+ db: DataDb,
+ provider: string,
+ accountId: string
+): Promise {
+ try {
+ await disconnectGoogleCalendar(accountId)
+ } catch (err) {
+ log.warn('Google Calendar disconnect failed', { accountId, err })
+ }
+
+ const allProviderSources = listCalendarSourceRows(db, { provider })
+ const targetSources = allProviderSources.filter((source) =>
+ source.kind === 'account' ? source.accountId === accountId : source.accountId === accountId
+ )
+
+ if (targetSources.length === 0) {
+ return {
+ success: true,
+ status: await buildProviderStatus(db, provider)
+ }
+ }
+
+ const pushRuntime = getGooglePushRuntime()
+ if (pushRuntime) {
+ for (const source of targetSources) {
+ if (source.kind !== 'calendar' || source.isMemryManaged) continue
+ void pushRuntime.handleSelectionToggle({
+ sourceId: source.id,
+ isSelected: false,
+ calendarId: source.remoteId
+ })
+ }
+ }
+
+ const targetSourceIds = targetSources.map((source) => source.id)
+ const externalRows =
+ targetSourceIds.length > 0
+ ? db
+ .select()
+ .from(calendarExternalEvents)
+ .where(inArray(calendarExternalEvents.sourceId, targetSourceIds))
+ .all()
+ : []
+
+ const bindingRows =
+ targetSourceIds.length > 0
+ ? db
+ .select()
+ .from(calendarBindings)
+ .where(
+ and(
+ eq(calendarBindings.provider, provider),
+ inArray(
+ calendarBindings.remoteCalendarId,
+ targetSources.map((s) => s.remoteId)
+ )
+ )
+ )
+ .all()
+ : []
+
+ const now = new Date().toISOString()
+
+ db.transaction((tx) => {
+ if (externalRows.length > 0) {
+ tx.delete(calendarExternalEvents)
+ .where(
+ inArray(
+ calendarExternalEvents.id,
+ externalRows.map((row) => row.id)
+ )
+ )
+ .run()
+ }
+
+ if (bindingRows.length > 0) {
+ tx.delete(calendarBindings)
+ .where(
+ inArray(
+ calendarBindings.id,
+ bindingRows.map((row) => row.id)
+ )
+ )
+ .run()
+ }
+
+ for (const source of targetSources) {
+ if (source.archivedAt) continue
+ tx.update(calendarSources)
+ .set({ archivedAt: now, modifiedAt: now })
+ .where(eq(calendarSources.id, source.id))
+ .run()
+ }
+ })
+
+ for (const row of externalRows) {
+ syncCalendarExternalEventDelete(row.id, JSON.stringify(row))
+ emitCalendarChanged({ entityType: 'calendar_external_event', id: row.id })
+ }
+
+ for (const row of bindingRows) {
+ syncCalendarBindingDelete(row.id, JSON.stringify(row))
+ emitCalendarChanged({ entityType: 'calendar_binding', id: row.id })
+ }
+
+ for (const source of targetSources) {
+ if (source.archivedAt) continue
+ syncCalendarSourceUpdate(source.id)
+ emitCalendarChanged({ entityType: 'calendar_source', id: source.id })
+ }
+
+ return {
+ success: true,
+ status: await buildProviderStatus(db, provider)
+ }
+}
+
export function registerCalendarHandlers(): void {
ipcMain.handle(
CalendarChannels.invoke.CREATE_EVENT,
@@ -523,6 +641,10 @@ export function registerCalendarHandlers(): void {
}
}
+ if (input.accountId) {
+ return await disconnectGoogleAccount(db, input.provider, input.accountId)
+ }
+
stopGoogleCalendarSyncRunner()
const accountIdsToDisconnect = listGoogleAccountIds(db)
for (const accountId of accountIdsToDisconnect) {
diff --git a/packages/contracts/src/calendar-api.ts b/packages/contracts/src/calendar-api.ts
index f8d4016d2..efda8c521 100644
--- a/packages/contracts/src/calendar-api.ts
+++ b/packages/contracts/src/calendar-api.ts
@@ -89,7 +89,8 @@ export const UpdateCalendarSourceSelectionSchema = z.object({
})
export const CalendarProviderRequestSchema = z.object({
- provider: z.string().min(1)
+ provider: z.string().min(1),
+ accountId: z.string().min(1).optional()
})
export type CalendarSourceKind = z.infer
From 6db947d82073c8de0fd72546a668d7395ecb633f Mon Sep 17 00:00:00 2001
From: Kaan Karaca
Date: Sun, 19 Apr 2026 22:09:04 +0300
Subject: [PATCH 7/9] feat(calendar): sync-health surface, lastError column,
retry IPC (M6 T6)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes G10. Adds the per-source health surface, persists the last
sync failure to a real column, and exposes a "Retry now" IPC so the
renderer can re-kick a single source.
Schema (migration 0028):
- `calendar_sources.last_error TEXT` — nullable. Hand-written SQL +
journal entry per the project convention since `pnpm db:generate`
drifts on these tables.
- `CalendarSourceRecord` gains `lastError: string | null`.
Sync-service:
- `syncGoogleCalendarSource` now wraps in `recordSyncError` on any
uncaught failure: writes `syncStatus='error'` + truncated 200-char
message to `lastError`, fires `markSyncedTableMutation` so the
failure propagates cross-device.
- On a clean run, `syncStatus='ok'` + `lastError=null` clears any
prior failure.
IPC:
- New `CalendarChannels.invoke.RETRY_GOOGLE_CALENDAR_SOURCE_SYNC`
('calendar:retry-google-source-sync') + `RetryCalendarSourceSync`
schema + response shape (`{ success, source, error? }`).
- Handler validates the source is `provider='google'`, kind='calendar',
invokes `syncGoogleCalendarSource(db, sourceId)`, returns the
refreshed `CalendarSourceRecord` so the renderer can update its
cache without a separate refetch.
Renderer:
- `GoogleCalendarSourcePicker` upgraded into the per-source health
view: status dot (ok=emerald / pending=amber / error=destructive /
idle=muted), status label, and — when status='error' — a "Retry
now" button + truncated error message (full text in the row's
`title` tooltip).
- `GoogleCalendarIntegrationRow` wires `retryGoogleCalendarSourceSync`
into a `useMutation` and forwards `onRetrySource` /
`retryingSourceId` props to the picker.
- `buildProviderAccountStatus` now reads `lastError` from the
dedicated column first, falling back to the M5-era `metadata.lastError`
for any legacy rows that still carry it there.
Tests:
- sync-service: "writes truncated lastError + syncStatus='error' when
listEvents throws" (asserts the 200-char cap), and "clears
lastError on a successful sync after a previous error".
- calendar-handlers: "RETRY_GOOGLE_CALENDAR_SOURCE_SYNC fires
syncGoogleCalendarSource and returns the refreshed source".
- google-calendar-integration-row: "shows a Retry button + lastError
on calendar sources in error state and fires retry IPC".
Generated IPC bindings re-run via `pnpm ipc:generate` to pick up the
new RETRY channel; `pnpm ipc:check` is green.
Out of scope here: dedicated `calendar_sync_status_changed`
projection event (current `CHANGED` event already triggers renderer
refetch via TanStack invalidation) and the calendar-header status
chip — both follow-ups.
---
.../main/calendar/google/sync-service.test.ts | 65 +++++++++++
.../src/main/calendar/google/sync-service.ts | 29 +++++
.../0028_calendar_source_last_error.sql | 1 +
.../database/drizzle-data/meta/_journal.json | 7 ++
.../src/main/ipc/calendar-handlers.test.ts | 45 ++++++++
.../desktop/src/main/ipc/calendar-handlers.ts | 44 +++++++-
.../src/main/ipc/generated-ipc-invoke-map.ts | 9 +-
apps/desktop/src/preload/generated-rpc.ts | 1 +
.../google-calendar-integration-row.test.tsx | 43 +++++++-
.../google-calendar-integration-row.tsx | 16 +++
.../google-calendar-source-picker.tsx | 102 +++++++++++++++---
.../renderer/src/services/calendar-service.ts | 10 ++
packages/contracts/src/calendar-api.ts | 13 +++
packages/contracts/src/ipc-channels.ts | 4 +-
.../db-schema/src/schema/calendar-sources.ts | 7 +-
packages/rpc/src/calendar.ts | 10 ++
16 files changed, 378 insertions(+), 28 deletions(-)
create mode 100644 apps/desktop/src/main/database/drizzle-data/0028_calendar_source_last_error.sql
diff --git a/apps/desktop/src/main/calendar/google/sync-service.test.ts b/apps/desktop/src/main/calendar/google/sync-service.test.ts
index a2f47ff1a..d6bf2fc1b 100644
--- a/apps/desktop/src/main/calendar/google/sync-service.test.ts
+++ b/apps/desktop/src/main/calendar/google/sync-service.test.ts
@@ -495,6 +495,71 @@ describe('google calendar sync service', () => {
})
})
+ it('writes truncated lastError + syncStatus="error" when listEvents throws (M6 T6)', async () => {
+ seedGoogleCalendarSource({
+ id: 'google-calendar:selected',
+ remoteId: 'remote-selected-calendar',
+ title: 'Work',
+ isMemryManaged: false
+ })
+
+ const longMessage = 'x'.repeat(500)
+ const client = {
+ listEvents: vi.fn(async () => {
+ throw new Error(longMessage)
+ })
+ }
+
+ await expect(
+ syncGoogleCalendarSource(db, 'google-calendar:selected', { client })
+ ).rejects.toThrow()
+
+ const failedSource = db
+ .select()
+ .from(calendarSources)
+ .where(eq(calendarSources.id, 'google-calendar:selected'))
+ .get()
+
+ expect(failedSource).toMatchObject({
+ syncStatus: 'error'
+ })
+ expect(failedSource?.lastError).toBeTruthy()
+ // Truncated to 200 chars per the M6 T6 default.
+ expect(failedSource?.lastError?.length).toBeLessThanOrEqual(200)
+ expect(failedSource?.lastError?.startsWith('xxxxx')).toBe(true)
+ })
+
+ it('clears lastError on a successful sync after a previous error (M6 T6)', async () => {
+ seedGoogleCalendarSource({
+ id: 'google-calendar:selected',
+ remoteId: 'remote-selected-calendar',
+ title: 'Work',
+ isMemryManaged: false,
+ syncStatus: 'error',
+ lastError: 'previous failure message'
+ })
+
+ const client = {
+ listEvents: vi.fn(async () => ({
+ nextSyncCursor: 'cursor-3',
+ events: []
+ }))
+ }
+
+ await syncGoogleCalendarSource(db, 'google-calendar:selected', { client })
+
+ const refreshed = db
+ .select()
+ .from(calendarSources)
+ .where(eq(calendarSources.id, 'google-calendar:selected'))
+ .get()
+
+ expect(refreshed).toMatchObject({
+ syncStatus: 'ok',
+ lastError: null
+ })
+ })
+
it('reconciles local source mutations by upserting scheduled items and deleting cleared bindings', async () => {
seedGoogleCalendarSource()
diff --git a/apps/desktop/src/main/calendar/google/sync-service.ts b/apps/desktop/src/main/calendar/google/sync-service.ts
index 4061356c6..704b7a394 100644
--- a/apps/desktop/src/main/calendar/google/sync-service.ts
+++ b/apps/desktop/src/main/calendar/google/sync-service.ts
@@ -783,6 +783,34 @@ export async function syncGoogleCalendarSource(
db: DataDb,
sourceId: string,
deps: { client?: Pick } = {}
+): Promise {
+ try {
+ await syncGoogleCalendarSourceInner(db, sourceId, deps)
+ } catch (error) {
+ recordSyncError(db, sourceId, error)
+ throw error
+ }
+}
+
+function recordSyncError(db: DataDb, sourceId: string, error: unknown): void {
+ const source = getCalendarSourceById(db, sourceId)
+ if (!source) return
+ const message = error instanceof Error ? error.message : String(error)
+ const truncated = message.slice(0, 200)
+ const updated = upsertCalendarSource(db, {
+ ...source,
+ syncStatus: 'error',
+ lastError: truncated,
+ modifiedAt: getNow()
+ })
+ markSyncedTableMutation('calendar_source', updated.id, true)
+ emitCalendarChanged({ entityType: 'calendar_source', id: updated.id })
+}
+
+async function syncGoogleCalendarSourceInner(
+ db: DataDb,
+ sourceId: string,
+ deps: { client?: Pick } = {}
): Promise {
const source = getCalendarSourceById(db, sourceId)
if (!source) {
@@ -875,6 +903,7 @@ export async function syncGoogleCalendarSource(
syncCursor: result.nextSyncCursor,
syncStatus: 'ok',
lastSyncedAt: now,
+ lastError: null,
modifiedAt: now
})
markSyncedTableMutation('calendar_source', updatedSource.id, true)
diff --git a/apps/desktop/src/main/database/drizzle-data/0028_calendar_source_last_error.sql b/apps/desktop/src/main/database/drizzle-data/0028_calendar_source_last_error.sql
new file mode 100644
index 000000000..d47484a02
--- /dev/null
+++ b/apps/desktop/src/main/database/drizzle-data/0028_calendar_source_last_error.sql
@@ -0,0 +1 @@
+ALTER TABLE `calendar_sources` ADD COLUMN `last_error` text;
diff --git a/apps/desktop/src/main/database/drizzle-data/meta/_journal.json b/apps/desktop/src/main/database/drizzle-data/meta/_journal.json
index 297f1a300..8e3676da8 100644
--- a/apps/desktop/src/main/database/drizzle-data/meta/_journal.json
+++ b/apps/desktop/src/main/database/drizzle-data/meta/_journal.json
@@ -197,6 +197,13 @@
"when": 1777248000000,
"tag": "0027_calendar_rich_fields",
"breakpoints": true
+ },
+ {
+ "idx": 28,
+ "version": "6",
+ "when": 1777680000000,
+ "tag": "0028_calendar_source_last_error",
+ "breakpoints": true
}
]
}
diff --git a/apps/desktop/src/main/ipc/calendar-handlers.test.ts b/apps/desktop/src/main/ipc/calendar-handlers.test.ts
index 27db8c8dc..9fa4c9b8a 100644
--- a/apps/desktop/src/main/ipc/calendar-handlers.test.ts
+++ b/apps/desktop/src/main/ipc/calendar-handlers.test.ts
@@ -19,6 +19,7 @@ const mockHasAnyGoogleCalendarLocalAuth = vi.fn()
const mockListGoogleAccountIds = vi.fn(() => [] as string[])
const mockResolveDefaultGoogleAccountId = vi.fn(() => null as string | null)
const mockSyncGoogleCalendarNow = vi.fn()
+const mockSyncGoogleCalendarSource = vi.fn()
const mockStartGoogleCalendarSyncRunner = vi.fn(async () => {})
const mockStopGoogleCalendarSyncRunner = vi.fn()
const mockIsMemryUserSignedIn = vi.fn(async () => true)
@@ -65,6 +66,7 @@ vi.mock('../calendar/google/oauth', () => ({
vi.mock('../calendar/google/sync-service', () => ({
syncGoogleCalendarNow: (...args: unknown[]) => mockSyncGoogleCalendarNow(...args),
+ syncGoogleCalendarSource: (...args: unknown[]) => mockSyncGoogleCalendarSource(...args),
startGoogleCalendarSyncRunner: (...args: unknown[]) => mockStartGoogleCalendarSyncRunner(...args),
stopGoogleCalendarSyncRunner: (...args: unknown[]) => mockStopGoogleCalendarSyncRunner(...args)
}))
@@ -644,6 +646,49 @@ describe('calendar-handlers', () => {
)
})
+ it('RETRY_GOOGLE_CALENDAR_SOURCE_SYNC fires syncGoogleCalendarSource and returns the refreshed source (M6 T6)', async () => {
+ registerCalendarHandlers()
+
+ db.run(sql`
+ INSERT INTO calendar_sources (
+ id, provider, kind, account_id, remote_id, title, timezone,
+ is_selected, sync_status, last_error, created_at, modified_at
+ ) VALUES (
+ ${'google-calendar:work'}, ${'google'}, ${'calendar'},
+ ${'alice@example.com'}, ${'work@cal'}, ${'Work'}, ${'UTC'},
+ ${1}, ${'error'}, ${'token expired'},
+ ${'2026-04-15T10:00:00.000Z'}, ${'2026-04-15T10:00:00.000Z'}
+ )
+ `)
+
+ mockSyncGoogleCalendarSource.mockImplementation(async (db, sourceId) => {
+ // Simulate a successful sync clearing the error.
+ db.run(sql`
+ UPDATE calendar_sources
+ SET sync_status = ${'ok'}, last_error = NULL, last_synced_at = ${'2026-04-19T10:00:00.000Z'}
+ WHERE id = ${sourceId}
+ `)
+ })
+
+ const result = await invokeHandler(CalendarChannels.invoke.RETRY_GOOGLE_CALENDAR_SOURCE_SYNC, {
+ sourceId: 'google-calendar:work'
+ })
+
+ expect(mockSyncGoogleCalendarSource).toHaveBeenCalledWith(
+ expect.anything(),
+ 'google-calendar:work'
+ )
+ expect(result.success).toBe(true)
+ expect(result.source).toEqual(
+ expect.objectContaining({
+ id: 'google-calendar:work',
+ syncStatus: 'ok',
+ lastError: null,
+ lastSyncedAt: '2026-04-19T10:00:00.000Z'
+ })
+ )
+ })
+
it('disconnects only the requested accountId, leaving other accounts intact (M6 T5)', async () => {
registerCalendarHandlers()
diff --git a/apps/desktop/src/main/ipc/calendar-handlers.ts b/apps/desktop/src/main/ipc/calendar-handlers.ts
index 53d8237b2..c3d32c8c1 100644
--- a/apps/desktop/src/main/ipc/calendar-handlers.ts
+++ b/apps/desktop/src/main/ipc/calendar-handlers.ts
@@ -8,6 +8,7 @@ import {
ListCalendarSourcesSchema,
ListGoogleCalendarsSchema,
PromoteExternalEventSchema,
+ RetryCalendarSourceSyncSchema,
SetDefaultGoogleCalendarSchema,
UpdateCalendarSourceSelectionSchema,
CalendarProviderRequestSchema,
@@ -27,6 +28,7 @@ import {
type CalendarSourceRecord,
type ListGoogleCalendarsResponse,
type PromoteExternalEventResponse,
+ type RetryCalendarSourceSyncResponse,
type SetDefaultGoogleCalendarResponse
} from '@memry/contracts/calendar-api'
import { calendarEvents } from '@memry/db-schema/schema/calendar-events'
@@ -54,7 +56,8 @@ import { getCalendarRangeProjection } from '../calendar/projection'
import {
startGoogleCalendarSyncRunner,
stopGoogleCalendarSyncRunner,
- syncGoogleCalendarNow
+ syncGoogleCalendarNow,
+ syncGoogleCalendarSource
} from '../calendar/google/sync-service'
import { listGoogleCalendars, setDefaultGoogleCalendar } from '../calendar/google/onboarding'
import { createGoogleCalendarClient } from '../calendar/google/client'
@@ -127,6 +130,7 @@ function mapCalendarSource(row: typeof calendarSources.$inferSelect): CalendarSo
syncCursor: row.syncCursor ?? null,
syncStatus: row.syncStatus,
lastSyncedAt: row.lastSyncedAt ?? null,
+ lastError: row.lastError ?? null,
metadata: (row.metadata as Record | null) ?? null,
archivedAt: row.archivedAt ?? null,
syncedAt: row.syncedAt ?? null,
@@ -159,7 +163,7 @@ async function buildProviderAccountStatus(
email: metadata?.email ?? source.title,
status,
lastSyncedAt: source.lastSyncedAt ?? null,
- lastError: metadata?.lastError ?? null
+ lastError: source.lastError ?? metadata?.lastError ?? null
}
}
@@ -788,6 +792,41 @@ export function registerCalendarHandlers(): void {
)
)
+ ipcMain.handle(
+ CalendarChannels.invoke.RETRY_GOOGLE_CALENDAR_SOURCE_SYNC,
+ createValidatedHandler(
+ RetryCalendarSourceSyncSchema,
+ withDb(async (db, input): Promise => {
+ const source = getCalendarSourceById(db, input.sourceId)
+ if (!source) {
+ return { success: false, source: null, error: 'Calendar source not found' }
+ }
+ if (source.provider !== 'google' || source.kind !== 'calendar') {
+ return {
+ success: false,
+ source: null,
+ error: 'Only Google calendar sources can be retried'
+ }
+ }
+ try {
+ await syncGoogleCalendarSource(db, source.id)
+ } catch (err) {
+ const updated = getCalendarSourceById(db, source.id)
+ return {
+ success: false,
+ source: updated ? mapCalendarSource(updated) : null,
+ error: err instanceof Error ? err.message : 'Sync failed'
+ }
+ }
+ const refreshed = getCalendarSourceById(db, source.id)
+ return {
+ success: true,
+ source: refreshed ? mapCalendarSource(refreshed) : null
+ }
+ }, 'Failed to retry Google Calendar source sync')
+ )
+ )
+
ipcMain.handle(
CalendarChannels.invoke.PROMOTE_EXTERNAL_EVENT,
createValidatedHandler(
@@ -825,4 +864,5 @@ export function unregisterCalendarHandlers(): void {
ipcMain.removeHandler(CalendarChannels.invoke.LIST_GOOGLE_CALENDARS)
ipcMain.removeHandler(CalendarChannels.invoke.SET_DEFAULT_GOOGLE_CALENDAR)
ipcMain.removeHandler(CalendarChannels.invoke.PROMOTE_EXTERNAL_EVENT)
+ ipcMain.removeHandler(CalendarChannels.invoke.RETRY_GOOGLE_CALENDAR_SOURCE_SYNC)
}
diff --git a/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts b/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts
index 171fbc836..6844bfbec 100644
--- a/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts
+++ b/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts
@@ -27,18 +27,19 @@ export interface MainIpcInvokeHandlers {
"bookmarks:list-by-type": (...args: [string]) => Awaited>
"bookmarks:reorder": (...args: [{ bookmarkIds: string[]; }]) => Awaited>
"bookmarks:toggle": (...args: [{ itemType: string; itemId: string; }]) => Awaited>
- "calendar:connect-provider": (...args: [{ provider: string; }]) => Awaited>
+ "calendar:connect-provider": (...args: [{ provider: string; accountId?: string | undefined; }]) => Awaited>
"calendar:create-event": (...args: [{ title: string; startAt: string; description?: string | null | undefined; location?: string | null | undefined; endAt?: string | null | undefined; timezone?: string | undefined; isAllDay?: boolean | undefined; recurrenceRule?: Record | null | undefined; recurrenceExceptions?: string[] | null | undefined; targetCalendarId?: string | null | undefined; }]) => Awaited>
"calendar:delete-event": (...args: [string]) => Awaited>
- "calendar:disconnect-provider": (...args: [{ provider: string; }]) => Awaited>
+ "calendar:disconnect-provider": (...args: [{ provider: string; accountId?: string | undefined; }]) => Awaited>
"calendar:get-event": (...args: [string]) => Awaited>
- "calendar:get-provider-status": (...args: [{ provider: string; }]) => Awaited>
+ "calendar:get-provider-status": (...args: [{ provider: string; accountId?: string | undefined; }]) => Awaited>
"calendar:get-range": (...args: [{ startAt: string; endAt: string; includeUnselectedSources?: boolean | undefined; }]) => Awaited>
"calendar:list-events": (...args: [{ includeArchived?: boolean | undefined; }]) => Awaited>
"calendar:list-google-calendars": (...args: [Record | undefined]) => Awaited>
"calendar:list-sources": (...args: [{ provider?: string | undefined; kind?: "account" | "calendar" | undefined; selectedOnly?: boolean | undefined; }]) => Awaited>
"calendar:promote-external-event": (...args: [{ externalEventId: string; }]) => Awaited>
- "calendar:refresh-provider": (...args: [{ provider: string; }]) => Awaited>
+ "calendar:refresh-provider": (...args: [{ provider: string; accountId?: string | undefined; }]) => Awaited>
+ "calendar:retry-google-source-sync": (...args: [{ sourceId: string; }]) => Awaited>
"calendar:set-default-google-calendar": (...args: [{ calendarId: string | null; markOnboardingComplete?: boolean | undefined; }]) => Awaited>
"calendar:update-event": (...args: [{ id: string; title?: string | undefined; description?: string | null | undefined; location?: string | null | undefined; startAt?: string | undefined; endAt?: string | null | undefined; timezone?: string | undefined; isAllDay?: boolean | undefined; recurrenceRule?: Record | null | undefined; recurrenceExceptions?: string[] | null | undefined; targetCalendarId?: string | null | undefined; }]) => Awaited>
"calendar:update-source-selection": (...args: [{ id: string; isSelected: boolean; }]) => Awaited>
diff --git a/apps/desktop/src/preload/generated-rpc.ts b/apps/desktop/src/preload/generated-rpc.ts
index 914ef15b5..b002276da 100644
--- a/apps/desktop/src/preload/generated-rpc.ts
+++ b/apps/desktop/src/preload/generated-rpc.ts
@@ -237,6 +237,7 @@ export function createGeneratedRpcApi({
listGoogleCalendars: ((options) => invoke("calendar:list-google-calendars", options ?? {})) as GeneratedRpcApi["calendar"]["listGoogleCalendars"],
setDefaultGoogleCalendar: ((input) => invoke("calendar:set-default-google-calendar", input)) as GeneratedRpcApi["calendar"]["setDefaultGoogleCalendar"],
promoteExternalEvent: ((input) => invoke("calendar:promote-external-event", input)) as GeneratedRpcApi["calendar"]["promoteExternalEvent"],
+ retryGoogleCalendarSourceSync: ((input) => invoke("calendar:retry-google-source-sync", input)) as GeneratedRpcApi["calendar"]["retryGoogleCalendarSourceSync"],
},
onNoteCreated: ((callback) => subscribe("notes:created", callback)) as GeneratedRpcApi["onNoteCreated"],
onNoteUpdated: ((callback) => subscribe("notes:updated", callback)) as GeneratedRpcApi["onNoteUpdated"],
diff --git a/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.test.tsx b/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.test.tsx
index 35a6bfc64..b0398fa8d 100644
--- a/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.test.tsx
+++ b/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.test.tsx
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { screen, waitFor } from '@testing-library/react'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders, userEvent } from '@tests/utils/render'
import { IntegrationList } from './integration-list'
import type { CalendarProviderStatus, CalendarSourceRecord } from '@/services/calendar-service'
@@ -10,14 +10,16 @@ const {
mockDisconnectGoogleCalendarProvider,
mockRefreshGoogleCalendarProvider,
mockListSources,
- mockUpdateSourceSelection
+ mockUpdateSourceSelection,
+ mockRetryGoogleCalendarSourceSync
} = vi.hoisted(() => ({
mockGetGoogleCalendarStatus: vi.fn(),
mockConnectGoogleCalendarProvider: vi.fn(),
mockDisconnectGoogleCalendarProvider: vi.fn(),
mockRefreshGoogleCalendarProvider: vi.fn(),
mockListSources: vi.fn(),
- mockUpdateSourceSelection: vi.fn()
+ mockUpdateSourceSelection: vi.fn(),
+ mockRetryGoogleCalendarSourceSync: vi.fn()
}))
vi.mock('@/services/calendar-service', () => ({
@@ -25,6 +27,7 @@ vi.mock('@/services/calendar-service', () => ({
connectGoogleCalendarProvider: mockConnectGoogleCalendarProvider,
disconnectGoogleCalendarProvider: mockDisconnectGoogleCalendarProvider,
refreshGoogleCalendarProvider: mockRefreshGoogleCalendarProvider,
+ retryGoogleCalendarSourceSync: mockRetryGoogleCalendarSourceSync,
updateGoogleCalendarSourceSelection: mockUpdateSourceSelection,
onCalendarChanged: vi.fn(() => () => {}),
calendarService: {
@@ -191,6 +194,7 @@ describe('Google Calendar integration row', () => {
mockRefreshGoogleCalendarProvider.mockReset()
mockListSources.mockReset()
mockUpdateSourceSelection.mockReset()
+ mockRetryGoogleCalendarSourceSync.mockReset()
})
it('starts the Google Calendar connect flow from Settings', async () => {
@@ -258,6 +262,39 @@ describe('Google Calendar integration row', () => {
})
})
+ it('shows a Retry button + lastError on calendar sources in error state and fires retry IPC (M6 T6)', async () => {
+ const erroredSources: CalendarSourceRecord[] = CONNECTED_SOURCES.map((source) =>
+ source.id === 'google-calendar-work'
+ ? { ...source, syncStatus: 'error', lastError: 'Quota exceeded for project 123' }
+ : source
+ )
+
+ mockGetGoogleCalendarStatus.mockResolvedValue(CONNECTED_STATUS)
+ mockListSources.mockResolvedValue({ sources: erroredSources })
+ mockRetryGoogleCalendarSourceSync.mockResolvedValue({
+ success: true,
+ source: { ...erroredSources[2], syncStatus: 'ok', lastError: null }
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => expect(screen.getByText('Work')).toBeInTheDocument())
+
+ const errorRow = screen.getByTestId('calendar-source-row-google-calendar-work')
+ expect(errorRow).toHaveAttribute('data-sync-status', 'error')
+ expect(screen.getByTestId('calendar-source-error-google-calendar-work')).toHaveTextContent(
+ 'Quota exceeded for project 123'
+ )
+
+ fireEvent.click(screen.getByTestId('calendar-source-retry-google-calendar-work'))
+
+ await waitFor(() => {
+ expect(mockRetryGoogleCalendarSourceSync).toHaveBeenCalledWith({
+ sourceId: 'google-calendar-work'
+ })
+ })
+ })
+
it('renders one chip per connected Google account with status + email (M6 T3)', async () => {
mockGetGoogleCalendarStatus.mockResolvedValue(TWO_ACCOUNT_STATUS)
mockListSources.mockResolvedValue({ sources: CONNECTED_SOURCES })
diff --git a/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.tsx b/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.tsx
index 330bee892..067d9e7fb 100644
--- a/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.tsx
+++ b/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.tsx
@@ -10,6 +10,7 @@ import {
disconnectGoogleCalendarProvider,
getGoogleCalendarStatus,
refreshGoogleCalendarProvider,
+ retryGoogleCalendarSourceSync,
updateGoogleCalendarSourceSelection
} from '@/services/calendar-service'
import { GoogleCalendarSourcePicker } from './google-calendar-source-picker'
@@ -98,6 +99,19 @@ export function GoogleCalendarIntegrationRow(): React.JSX.Element {
}
})
+ const retryMutation = useMutation({
+ mutationFn: async (sourceId: string) => {
+ const result = await retryGoogleCalendarSourceSync({ sourceId })
+ if (!result.success) {
+ throw new Error(result.error ?? 'Retry failed')
+ }
+ return result
+ },
+ onSuccess: async () => {
+ await invalidateGoogleCalendarQueries(queryClient)
+ }
+ })
+
// Re-open onboarding for users who connected before M2 shipped OR who
// closed the dialog last time without picking a default. Single auto-open
// per mount via the ref above; settings.onboardingCompleted flips to true
@@ -249,6 +263,8 @@ export function GoogleCalendarIntegrationRow(): React.JSX.Element {
onToggleSource={(sourceId, isSelected) =>
sourceMutation.mutate({ sourceId, isSelected })
}
+ onRetrySource={(sourceId) => retryMutation.mutate(sourceId)}
+ retryingSourceId={retryMutation.isPending ? (retryMutation.variables ?? null) : null}
/>
)}
diff --git a/apps/desktop/src/renderer/src/components/settings/google-calendar-source-picker.tsx b/apps/desktop/src/renderer/src/components/settings/google-calendar-source-picker.tsx
index 3773ca7bc..57b4132ec 100644
--- a/apps/desktop/src/renderer/src/components/settings/google-calendar-source-picker.tsx
+++ b/apps/desktop/src/renderer/src/components/settings/google-calendar-source-picker.tsx
@@ -1,3 +1,4 @@
+import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import type { CalendarSourceRecord } from '@/services/calendar-service'
@@ -5,12 +6,42 @@ interface GoogleCalendarSourcePickerProps {
sources: CalendarSourceRecord[]
isUpdating: boolean
onToggleSource: (sourceId: string, isSelected: boolean) => void
+ onRetrySource?: (sourceId: string) => void
+ retryingSourceId?: string | null
+}
+
+function statusDotClass(status: CalendarSourceRecord['syncStatus']): string {
+ switch (status) {
+ case 'ok':
+ return 'bg-emerald-500'
+ case 'error':
+ return 'bg-destructive'
+ case 'pending':
+ return 'bg-amber-500'
+ default:
+ return 'bg-muted-foreground/40'
+ }
+}
+
+function statusLabel(status: CalendarSourceRecord['syncStatus']): string {
+ switch (status) {
+ case 'ok':
+ return 'Synced'
+ case 'error':
+ return 'Error'
+ case 'pending':
+ return 'Pending'
+ default:
+ return 'Idle'
+ }
}
export function GoogleCalendarSourcePicker({
sources,
isUpdating,
- onToggleSource
+ onToggleSource,
+ onRetrySource,
+ retryingSourceId
}: GoogleCalendarSourcePickerProps): React.JSX.Element {
if (sources.length === 0) {
return (
@@ -24,27 +55,64 @@ export function GoogleCalendarSourcePicker({
{sources.map((source) => {
const inputId = `google-calendar-source-${source.id}`
+ const isError = source.syncStatus === 'error'
+ const isRetrying = retryingSourceId === source.id
return (
-
-
-
onToggleSource(source.id, checked === true)}
- aria-label={source.title}
- />
+
+
+
+
+
+
+ {statusLabel(source.syncStatus)}
+
+ {isError && onRetrySource && (
+
+ )}
+ onToggleSource(source.id, checked === true)}
+ aria-label={source.title}
+ />
+
+
+
+ {isError && source.lastError && (
+
+ {source.lastError}
+
+ )}
)
})}
diff --git a/apps/desktop/src/renderer/src/services/calendar-service.ts b/apps/desktop/src/renderer/src/services/calendar-service.ts
index d84936d3f..c10e90b41 100644
--- a/apps/desktop/src/renderer/src/services/calendar-service.ts
+++ b/apps/desktop/src/renderer/src/services/calendar-service.ts
@@ -21,6 +21,8 @@ import type {
ListGoogleCalendarsResponse,
PromoteExternalEventInput,
PromoteExternalEventResponse,
+ RetryCalendarSourceSyncInput,
+ RetryCalendarSourceSyncResponse,
SetDefaultGoogleCalendarInput,
SetDefaultGoogleCalendarResponse,
UpdateCalendarSourceSelectionInput,
@@ -51,6 +53,8 @@ export type {
ListGoogleCalendarsResponse,
PromoteExternalEventInput,
PromoteExternalEventResponse,
+ RetryCalendarSourceSyncInput,
+ RetryCalendarSourceSyncResponse,
SetDefaultGoogleCalendarInput,
SetDefaultGoogleCalendarResponse,
UpdateCalendarSourceSelectionInput,
@@ -102,3 +106,9 @@ export function promoteExternalCalendarEvent(
): Promise
{
return calendarService.promoteExternalEvent(input)
}
+
+export function retryGoogleCalendarSourceSync(
+ input: RetryCalendarSourceSyncInput
+): Promise {
+ return calendarService.retryGoogleCalendarSourceSync(input)
+}
diff --git a/packages/contracts/src/calendar-api.ts b/packages/contracts/src/calendar-api.ts
index efda8c521..bf9a2a6d0 100644
--- a/packages/contracts/src/calendar-api.ts
+++ b/packages/contracts/src/calendar-api.ts
@@ -93,6 +93,18 @@ export const CalendarProviderRequestSchema = z.object({
accountId: z.string().min(1).optional()
})
+export const RetryCalendarSourceSyncSchema = z.object({
+ sourceId: z.string().min(1)
+})
+
+export type RetryCalendarSourceSyncInput = z.infer
+
+export interface RetryCalendarSourceSyncResponse {
+ success: boolean
+ source: CalendarSourceRecord | null
+ error?: string
+}
+
export type CalendarSourceKind = z.infer
export type CalendarSourceSyncStatus = z.infer
export type CalendarProjectionSourceType = z.infer
@@ -185,6 +197,7 @@ export interface CalendarSourceRecord {
syncCursor: string | null
syncStatus: CalendarSourceSyncStatus
lastSyncedAt: string | null
+ lastError: string | null
metadata: Record | null
archivedAt: string | null
syncedAt: string | null
diff --git a/packages/contracts/src/ipc-channels.ts b/packages/contracts/src/ipc-channels.ts
index 9a476a5b1..e1971341d 100644
--- a/packages/contracts/src/ipc-channels.ts
+++ b/packages/contracts/src/ipc-channels.ts
@@ -541,7 +541,9 @@ export const CalendarChannels = {
/** M2: list the user's Google calendars for target/default selection */
LIST_GOOGLE_CALENDARS: 'calendar:list-google-calendars',
/** M2: persist the onboarding choice for default target Google calendar */
- SET_DEFAULT_GOOGLE_CALENDAR: 'calendar:set-default-google-calendar'
+ SET_DEFAULT_GOOGLE_CALENDAR: 'calendar:set-default-google-calendar',
+ /** M6: re-run sync for a single calendar source (Retry button on sync-health UI) */
+ RETRY_GOOGLE_CALENDAR_SOURCE_SYNC: 'calendar:retry-google-source-sync'
},
events: {
CHANGED: 'calendar:changed'
diff --git a/packages/db-schema/src/schema/calendar-sources.ts b/packages/db-schema/src/schema/calendar-sources.ts
index 970784fa8..e6d34e56f 100644
--- a/packages/db-schema/src/schema/calendar-sources.ts
+++ b/packages/db-schema/src/schema/calendar-sources.ts
@@ -22,6 +22,7 @@ export const calendarSources = sqliteTable(
syncCursor: text('sync_cursor'),
syncStatus: text('sync_status').$type().notNull().default('idle'),
lastSyncedAt: text('last_synced_at'),
+ lastError: text('last_error'),
metadata: text('metadata', { mode: 'json' }).$type | null>(),
archivedAt: text('archived_at'),
clock: text('clock', { mode: 'json' }).$type(),
@@ -34,7 +35,11 @@ export const calendarSources = sqliteTable(
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`)
},
(table) => [
- uniqueIndex('idx_calendar_sources_provider_remote').on(table.provider, table.kind, table.remoteId),
+ uniqueIndex('idx_calendar_sources_provider_remote').on(
+ table.provider,
+ table.kind,
+ table.remoteId
+ ),
index('idx_calendar_sources_account').on(table.accountId),
index('idx_calendar_sources_selected').on(table.isSelected)
]
diff --git a/packages/rpc/src/calendar.ts b/packages/rpc/src/calendar.ts
index c836d08f8..7757a11fd 100644
--- a/packages/rpc/src/calendar.ts
+++ b/packages/rpc/src/calendar.ts
@@ -8,6 +8,7 @@ import {
GetCalendarRangeSchema,
ListCalendarSourcesSchema,
PromoteExternalEventSchema,
+ RetryCalendarSourceSyncSchema,
SetDefaultGoogleCalendarSchema,
UpdateCalendarSourceSelectionSchema,
CalendarProviderRequestSchema,
@@ -25,6 +26,7 @@ import {
type CalendarSourceRecord,
type ListGoogleCalendarsResponse,
type PromoteExternalEventResponse,
+ type RetryCalendarSourceSyncResponse,
type SetDefaultGoogleCalendarResponse
} from '../../contracts/src/calendar-api.ts'
import {
@@ -45,6 +47,7 @@ export type CalendarProviderRequest = z.input
export type PromoteExternalEventInput = z.input
export type SetDefaultGoogleCalendarInput = z.input
+export type RetryCalendarSourceSyncInput = z.input
export type {
CalendarChangedEvent,
@@ -61,6 +64,7 @@ export type {
CalendarSourceRecord,
ListGoogleCalendarsResponse,
PromoteExternalEventResponse,
+ RetryCalendarSourceSyncResponse,
SetDefaultGoogleCalendarResponse
}
@@ -153,6 +157,12 @@ export const calendarRpc = defineDomain({
>({
channel: CalendarChannels.invoke.PROMOTE_EXTERNAL_EVENT,
params: ['input']
+ }),
+ retryGoogleCalendarSourceSync: defineMethod<
+ (input: RetryCalendarSourceSyncInput) => Promise
+ >({
+ channel: CalendarChannels.invoke.RETRY_GOOGLE_CALENDAR_SOURCE_SYNC,
+ params: ['input']
})
},
events: {
From b607d079280c9f80172a92f171729a163374a3c1 Mon Sep 17 00:00:00 2001
From: Kaan Karaca
Date: Sun, 19 Apr 2026 22:17:14 +0300
Subject: [PATCH 8/9] refactor(calendar): extract push-conflict-retry from
sync-service (M6 T7)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`sync-service.ts` crossed the 800-line eslint cap after M6 T6's
sync-health additions. Extracted three pure functions to a new
sibling file `push-conflict-retry.ts`:
- `loadSourceAsGoogleEvent(db, target)` — maps a CalendarSyncTarget
to a `GoogleCalendarUpsertEventInput`
- `pushEventWithConflictRetry(db, target, client, calendarId, binding)`
— the M3 412 conflict loop (3 retries, merges remote, marks
binding `remoteVersion='conflict'` on exhaustion)
- `mergeRemoteEventIntoLocal(db, eventId, remote)` — the M3 field-
clock-aware merge that keeps the local doc clock untouched
No behaviour change. sync-service drops from 949→760 lines; the
extracted module is ~180 lines and self-contained (no circular
imports — it depends on mappers, field-merge, and change-events but
not on sync-service). Pre-existing test surface continues to cover
both files.
---
.../calendar/google/push-conflict-retry.ts | 181 ++++++++++++++++++
.../src/main/calendar/google/sync-service.ts | 178 +----------------
2 files changed, 188 insertions(+), 171 deletions(-)
create mode 100644 apps/desktop/src/main/calendar/google/push-conflict-retry.ts
diff --git a/apps/desktop/src/main/calendar/google/push-conflict-retry.ts b/apps/desktop/src/main/calendar/google/push-conflict-retry.ts
new file mode 100644
index 000000000..9d55edf83
--- /dev/null
+++ b/apps/desktop/src/main/calendar/google/push-conflict-retry.ts
@@ -0,0 +1,181 @@
+import { eq } from 'drizzle-orm'
+import { calendarBindings } from '@memry/db-schema/schema/calendar-bindings'
+import { calendarEvents } from '@memry/db-schema/schema/calendar-events'
+import { inboxItems } from '@memry/db-schema/schema/inbox'
+import { reminders } from '@memry/db-schema/schema/reminders'
+import { tasks } from '@memry/db-schema/schema/tasks'
+import type { FieldClocks, VectorClock } from '@memry/contracts/sync-api'
+import { createLogger } from '../../lib/logger'
+import type { DataDb } from '../../database/types'
+import { enqueueLocalSyncUpdate } from '../../sync/local-mutations'
+import { initAllFieldClocks } from '../../sync/field-merge'
+import { CALENDAR_EVENT_SYNCABLE_FIELDS, mergeCalendarEventFields } from '../field-merge-calendar'
+import { emitCalendarChanged } from '../change-events'
+import {
+ mapCalendarEventToGoogleInput,
+ mapGoogleEventToCalendarEventChanges,
+ mapInboxSnoozeToGoogleInput,
+ mapReminderToGoogleInput,
+ mapTaskToGoogleInput
+} from './mappers'
+import type {
+ CalendarSyncTarget,
+ GoogleCalendarClient,
+ GoogleCalendarRemoteEvent,
+ GoogleCalendarUpsertEventInput
+} from '../types'
+
+const log = createLogger('Calendar:GooglePushConflict')
+export const MAX_PUSH_CONFLICT_RETRIES = 3
+
+function getNow(): string {
+ return new Date().toISOString()
+}
+
+function isPreconditionFailedError(error: unknown): boolean {
+ return (
+ typeof error === 'object' &&
+ error !== null &&
+ 'status' in error &&
+ (error as { status: unknown }).status === 412
+ )
+}
+
+export function loadSourceAsGoogleEvent(
+ db: DataDb,
+ target: CalendarSyncTarget
+): GoogleCalendarUpsertEventInput {
+ switch (target.sourceType) {
+ case 'event': {
+ const row = db
+ .select()
+ .from(calendarEvents)
+ .where(eq(calendarEvents.id, target.sourceId))
+ .get()
+ if (!row) throw new Error(`Calendar event not found: ${target.sourceId}`)
+ return mapCalendarEventToGoogleInput(row)
+ }
+
+ case 'task': {
+ const row = db.select().from(tasks).where(eq(tasks.id, target.sourceId)).get()
+ if (!row) throw new Error(`Task not found: ${target.sourceId}`)
+ return mapTaskToGoogleInput(row)
+ }
+
+ case 'reminder': {
+ const row = db.select().from(reminders).where(eq(reminders.id, target.sourceId)).get()
+ if (!row) throw new Error(`Reminder not found: ${target.sourceId}`)
+ return mapReminderToGoogleInput(row)
+ }
+
+ case 'inbox_snooze': {
+ const row = db.select().from(inboxItems).where(eq(inboxItems.id, target.sourceId)).get()
+ if (!row) throw new Error(`Inbox item not found: ${target.sourceId}`)
+ return mapInboxSnoozeToGoogleInput(row)
+ }
+ }
+}
+
+export async function pushEventWithConflictRetry(
+ db: DataDb,
+ target: CalendarSyncTarget,
+ client: Pick,
+ resolvedCalendarId: string,
+ existingBinding: typeof calendarBindings.$inferSelect | undefined
+): Promise {
+ let ifMatch: string | null = existingBinding?.remoteVersion ?? null
+
+ for (let attempt = 0; attempt < MAX_PUSH_CONFLICT_RETRIES; attempt++) {
+ const localEvent = loadSourceAsGoogleEvent(db, target)
+ try {
+ return await client.upsertEvent({
+ calendarId: resolvedCalendarId,
+ eventId: existingBinding?.remoteEventId ?? null,
+ event: localEvent,
+ ifMatch
+ })
+ } catch (error) {
+ if (!isPreconditionFailedError(error) || !existingBinding?.remoteEventId) {
+ throw error
+ }
+
+ const remote = await client.getEvent({
+ calendarId: resolvedCalendarId,
+ eventId: existingBinding.remoteEventId
+ })
+
+ if (target.sourceType === 'event') {
+ mergeRemoteEventIntoLocal(db, target.sourceId, remote)
+ }
+
+ ifMatch = remote.etag ?? null
+ log.warn('Google upsert returned 412; merged remote and retrying', {
+ sourceType: target.sourceType,
+ sourceId: target.sourceId,
+ attempt: attempt + 1,
+ nextIfMatch: ifMatch
+ })
+ }
+ }
+
+ if (existingBinding) {
+ db.update(calendarBindings)
+ .set({ remoteVersion: 'conflict', modifiedAt: getNow() })
+ .where(eq(calendarBindings.id, existingBinding.id))
+ .run()
+ enqueueLocalSyncUpdate('calendar_binding', existingBinding.id)
+ }
+
+ log.error('Google upsert exhausted conflict retries', {
+ sourceType: target.sourceType,
+ sourceId: target.sourceId,
+ attempts: MAX_PUSH_CONFLICT_RETRIES
+ })
+ throw new Error(
+ `Google calendar push gave up after ${MAX_PUSH_CONFLICT_RETRIES} 412 conflicts for ${target.sourceType}:${target.sourceId}`
+ )
+}
+
+function mergeRemoteEventIntoLocal(
+ db: DataDb,
+ eventId: string,
+ remote: GoogleCalendarRemoteEvent
+): void {
+ const existing = db.select().from(calendarEvents).where(eq(calendarEvents.id, eventId)).get()
+ if (!existing) return
+
+ const remoteData = mapGoogleEventToCalendarEventChanges(remote)
+ const localFC: FieldClocks =
+ (existing.fieldClocks as FieldClocks | null) ??
+ initAllFieldClocks((existing.clock as VectorClock | null) ?? {}, CALENDAR_EVENT_SYNCABLE_FIELDS)
+ const remoteFC: FieldClocks = {}
+ for (const field of CALENDAR_EVENT_SYNCABLE_FIELDS) {
+ remoteFC[field] = { ...(localFC[field] ?? {}) }
+ }
+
+ const remoteForMerge: Record = {}
+ for (const field of CALENDAR_EVENT_SYNCABLE_FIELDS) {
+ const remoteVal = (remoteData as unknown as Record)[field]
+ remoteForMerge[field] =
+ remoteVal === undefined ? (existing as Record)[field] : remoteVal
+ }
+
+ const result = mergeCalendarEventFields(
+ existing as Record,
+ remoteForMerge,
+ localFC,
+ remoteFC
+ )
+
+ db.update(calendarEvents)
+ .set({
+ ...result.merged,
+ fieldClocks: result.mergedFieldClocks,
+ modifiedAt: getNow()
+ })
+ .where(eq(calendarEvents.id, eventId))
+ .run()
+
+ enqueueLocalSyncUpdate('calendar_event', eventId, [])
+ emitCalendarChanged({ entityType: 'calendar_event', id: eventId })
+}
diff --git a/apps/desktop/src/main/calendar/google/sync-service.ts b/apps/desktop/src/main/calendar/google/sync-service.ts
index 704b7a394..4f447e5e5 100644
--- a/apps/desktop/src/main/calendar/google/sync-service.ts
+++ b/apps/desktop/src/main/calendar/google/sync-service.ts
@@ -12,23 +12,18 @@ import {
enqueueLocalSyncDelete,
enqueueLocalSyncUpdate
} from '../../sync/local-mutations'
-import { initAllFieldClocks } from '../../sync/field-merge'
-import { CALENDAR_EVENT_SYNCABLE_FIELDS, mergeCalendarEventFields } from '../field-merge-calendar'
-import type { FieldClocks, VectorClock } from '@memry/contracts/sync-api'
import { publishProjectionEvent } from '../../projections'
import { hasGoogleCalendarConnection, resolveDefaultGoogleAccountId } from './oauth'
import { resolveTargetGoogleAccountId } from './account-routing'
+import { loadSourceAsGoogleEvent, pushEventWithConflictRetry } from './push-conflict-retry'
import { isMemryUserSignedIn } from '../../sync/auth-state'
import { createGoogleCalendarClient } from './client'
+import { CALENDAR_EVENT_SYNCABLE_FIELDS } from '../field-merge-calendar'
import {
- mapCalendarEventToGoogleInput,
mapGoogleEventToCalendarEventChanges,
mapGoogleEventToExternalEventRecord,
mapGoogleEventToReminderAt,
- mapGoogleEventToTaskSchedule,
- mapInboxSnoozeToGoogleInput,
- mapReminderToGoogleInput,
- mapTaskToGoogleInput
+ mapGoogleEventToTaskSchedule
} from './mappers'
import {
getCalendarExternalEventById,
@@ -44,12 +39,7 @@ import {
} from '../repositories/calendar-sources-repository'
import { emitCalendarChanged, emitCalendarProjectionChanged } from '../change-events'
import { readCalendarGoogleSettings } from './calendar-google-settings'
-import type {
- CalendarSyncTarget,
- GoogleCalendarClient,
- GoogleCalendarRemoteEvent,
- GoogleCalendarUpsertEventInput
-} from '../types'
+import type { CalendarSyncTarget, GoogleCalendarClient, GoogleCalendarRemoteEvent } from '../types'
const log = createLogger('Calendar:GoogleSync')
const LOCAL_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
@@ -69,129 +59,8 @@ function isGoneError(error: unknown): boolean {
)
}
-function isPreconditionFailedError(error: unknown): boolean {
- return (
- typeof error === 'object' &&
- error !== null &&
- 'status' in error &&
- (error as { status: unknown }).status === 412
- )
-}
-
-const MAX_PUSH_CONFLICT_RETRIES = 3
-
-async function pushEventWithConflictRetry(
- db: DataDb,
- target: CalendarSyncTarget,
- client: Pick,
- resolvedCalendarId: string,
- existingBinding: typeof calendarBindings.$inferSelect | undefined
-): Promise {
- let ifMatch: string | null = existingBinding?.remoteVersion ?? null
-
- for (let attempt = 0; attempt < MAX_PUSH_CONFLICT_RETRIES; attempt++) {
- const localEvent = loadSourceAsGoogleEvent(db, target)
- try {
- return await client.upsertEvent({
- calendarId: resolvedCalendarId,
- eventId: existingBinding?.remoteEventId ?? null,
- event: localEvent,
- ifMatch
- })
- } catch (error) {
- if (!isPreconditionFailedError(error) || !existingBinding?.remoteEventId) {
- throw error
- }
-
- const remote = await client.getEvent({
- calendarId: resolvedCalendarId,
- eventId: existingBinding.remoteEventId
- })
-
- if (target.sourceType === 'event') {
- mergeRemoteEventIntoLocal(db, target.sourceId, remote)
- }
-
- ifMatch = remote.etag ?? null
- log.warn('Google upsert returned 412; merged remote and retrying', {
- sourceType: target.sourceType,
- sourceId: target.sourceId,
- attempt: attempt + 1,
- nextIfMatch: ifMatch
- })
- }
- }
-
- if (existingBinding) {
- db.update(calendarBindings)
- .set({ remoteVersion: 'conflict', modifiedAt: getNow() })
- .where(eq(calendarBindings.id, existingBinding.id))
- .run()
- enqueueLocalSyncUpdate('calendar_binding', existingBinding.id)
- }
-
- log.error('Google upsert exhausted conflict retries', {
- sourceType: target.sourceType,
- sourceId: target.sourceId,
- attempts: MAX_PUSH_CONFLICT_RETRIES
- })
- throw new Error(
- `Google calendar push gave up after ${MAX_PUSH_CONFLICT_RETRIES} 412 conflicts for ${target.sourceType}:${target.sourceId}`
- )
-}
-
-function mergeRemoteEventIntoLocal(
- db: DataDb,
- eventId: string,
- remote: GoogleCalendarRemoteEvent
-): void {
- const existing = db.select().from(calendarEvents).where(eq(calendarEvents.id, eventId)).get()
- if (!existing) return
-
- const remoteData = mapGoogleEventToCalendarEventChanges(remote)
- const localFC: FieldClocks =
- (existing.fieldClocks as FieldClocks | null) ??
- initAllFieldClocks((existing.clock as VectorClock | null) ?? {}, CALENDAR_EVENT_SYNCABLE_FIELDS)
- // We can't tell from Google's REST surface which fields the remote changed.
- // Clone the local per-field clocks so every field merges as a "concurrent"
- // edit at equal tick-sum — the merge function's tiebreak keeps the remote
- // value when it actually differs and leaves matching fields untouched.
- // Cloning (vs. fabricating a synthetic device) avoids polluting the doc-level
- // clock with a fake `google-remote` actor and keeps subsequent retries
- // monotonic: the next 412 reads the *updated* local FC as its base.
- const remoteFC: FieldClocks = {}
- for (const field of CALENDAR_EVENT_SYNCABLE_FIELDS) {
- remoteFC[field] = { ...(localFC[field] ?? {}) }
- }
-
- const remoteForMerge: Record = {}
- for (const field of CALENDAR_EVENT_SYNCABLE_FIELDS) {
- const remoteVal = (remoteData as unknown as Record)[field]
- remoteForMerge[field] =
- remoteVal === undefined ? (existing as Record)[field] : remoteVal
- }
-
- const result = mergeCalendarEventFields(
- existing as Record,
- remoteForMerge,
- localFC,
- remoteFC
- )
-
- db.update(calendarEvents)
- .set({
- ...result.merged,
- fieldClocks: result.mergedFieldClocks,
- modifiedAt: getNow()
- })
- .where(eq(calendarEvents.id, eventId))
- .run()
-
- // Field clocks were already merged in this transaction; tell the producer
- // not to re-increment them by passing an empty changed-fields list.
- enqueueLocalSyncUpdate('calendar_event', eventId, [])
- emitCalendarChanged({ entityType: 'calendar_event', id: eventId })
-}
+// pushEventWithConflictRetry, mergeRemoteEventIntoLocal, and
+// loadSourceAsGoogleEvent extracted to push-conflict-retry.ts for max-lines.
function markSyncedTableMutation(
entityType: 'calendar_binding' | 'calendar_source' | 'calendar_external_event',
@@ -419,40 +288,7 @@ function shouldSourceSyncToGoogleCalendar(db: DataDb, target: CalendarSyncTarget
}
}
-function loadSourceAsGoogleEvent(
- db: DataDb,
- target: CalendarSyncTarget
-): GoogleCalendarUpsertEventInput {
- switch (target.sourceType) {
- case 'event': {
- const row = db
- .select()
- .from(calendarEvents)
- .where(eq(calendarEvents.id, target.sourceId))
- .get()
- if (!row) throw new Error(`Calendar event not found: ${target.sourceId}`)
- return mapCalendarEventToGoogleInput(row)
- }
-
- case 'task': {
- const row = db.select().from(tasks).where(eq(tasks.id, target.sourceId)).get()
- if (!row) throw new Error(`Task not found: ${target.sourceId}`)
- return mapTaskToGoogleInput(row)
- }
-
- case 'reminder': {
- const row = db.select().from(reminders).where(eq(reminders.id, target.sourceId)).get()
- if (!row) throw new Error(`Reminder not found: ${target.sourceId}`)
- return mapReminderToGoogleInput(row)
- }
-
- case 'inbox_snooze': {
- const row = db.select().from(inboxItems).where(eq(inboxItems.id, target.sourceId)).get()
- if (!row) throw new Error(`Inbox item not found: ${target.sourceId}`)
- return mapInboxSnoozeToGoogleInput(row)
- }
- }
-}
+// loadSourceAsGoogleEvent moved to push-conflict-retry.ts
function updateBindingRemoteVersion(
db: DataDb,
From 544a10b9123e81b2cac9b941bfe10fc5cb6b8a23 Mon Sep 17 00:00:00 2001
From: Kaan Karaca
Date: Sun, 19 Apr 2026 23:05:44 +0300
Subject: [PATCH 9/9] fix calendar multi-account google routing
---
.../google/google-channel-manager.test.ts | 36 ++++++
.../calendar/google/google-channel-manager.ts | 14 +-
.../src/main/calendar/google/push-runtime.ts | 23 +++-
.../main/calendar/google/sync-service.test.ts | 121 +++++++++++++++++-
.../src/main/calendar/google/sync-service.ts | 75 ++++++++---
.../google-calendar-integration-row.test.tsx | 4 +-
.../google-calendar-source-picker.tsx | 11 +-
7 files changed, 250 insertions(+), 34 deletions(-)
diff --git a/apps/desktop/src/main/calendar/google/google-channel-manager.test.ts b/apps/desktop/src/main/calendar/google/google-channel-manager.test.ts
index 75ff5da28..71555039b 100644
--- a/apps/desktop/src/main/calendar/google/google-channel-manager.test.ts
+++ b/apps/desktop/src/main/calendar/google/google-channel-manager.test.ts
@@ -145,6 +145,42 @@ describe('google-channel-manager', () => {
expect(onActiveCountChange).toHaveBeenCalledWith(1)
})
+
+ it('resolves a client per source and reuses that client for stop operations', async () => {
+ const clientA = {
+ watchCalendar: vi.fn(async (input: { channelId: string }) => ({
+ resourceId: `resource-${input.channelId}`,
+ expiration: FIXED_NOW_MS + TTL_SECONDS * 1000
+ })),
+ stopChannel: vi.fn(async () => {})
+ }
+ const clientB = {
+ watchCalendar: vi.fn(async (input: { channelId: string }) => ({
+ resourceId: `resource-${input.channelId}`,
+ expiration: FIXED_NOW_MS + TTL_SECONDS * 1000
+ })),
+ stopChannel: vi.fn(async () => {})
+ }
+ const resolveClient = vi.fn(
+ ({ sourceId }: { sourceId: string }) => (sourceId === 'src-1' ? clientA : clientB)
+ )
+ const deps = buildDeps({ resolveClient })
+ const mgr = createGoogleChannelManager(deps)
+
+ await mgr.ensureChannelForSource({ sourceId: 'src-1', calendarId: 'cal-a' })
+ await mgr.ensureChannelForSource({ sourceId: 'src-2', calendarId: 'cal-b' })
+ await mgr.stopForSource('src-2')
+
+ expect(resolveClient).toHaveBeenCalledWith({ sourceId: 'src-1', calendarId: 'cal-a' })
+ expect(resolveClient).toHaveBeenCalledWith({ sourceId: 'src-2', calendarId: 'cal-b' })
+ expect(clientA.watchCalendar).toHaveBeenCalledTimes(1)
+ expect(clientB.watchCalendar).toHaveBeenCalledTimes(1)
+ expect(clientB.stopChannel).toHaveBeenCalledWith({
+ channelId: 'channel-2',
+ resourceId: 'resource-channel-2'
+ })
+ expect(deps.client.watchCalendar).not.toHaveBeenCalled()
+ })
})
describe('stopForSource', () => {
diff --git a/apps/desktop/src/main/calendar/google/google-channel-manager.ts b/apps/desktop/src/main/calendar/google/google-channel-manager.ts
index d14e4c6a3..8c3003574 100644
--- a/apps/desktop/src/main/calendar/google/google-channel-manager.ts
+++ b/apps/desktop/src/main/calendar/google/google-channel-manager.ts
@@ -3,8 +3,11 @@ import type { GoogleCalendarClient } from '../types'
const log = createLogger('Calendar:GoogleChannelManager')
+type GoogleChannelClient = Pick
+
export interface GoogleChannelManagerDeps {
- client: Pick
+ client: GoogleChannelClient
+ resolveClient?(input: { sourceId: string; calendarId: string }): GoogleChannelClient
registerOnServer(input: {
channelId: string
sourceId: string
@@ -38,6 +41,7 @@ interface ChannelState {
resourceId: string
expirationMs: number
rotationTimer: ReturnType
+ client: GoogleChannelClient
}
export function createGoogleChannelManager(deps: GoogleChannelManagerDeps): GoogleChannelManager {
@@ -54,10 +58,11 @@ export function createGoogleChannelManager(deps: GoogleChannelManagerDeps): Goog
const nowMs = (deps.now ?? Date.now)()
const expirationMs = nowMs + deps.ttlSeconds * 1000
const expiresAt = Math.floor(nowMs / 1000) + deps.ttlSeconds
+ const client = deps.resolveClient?.({ sourceId, calendarId }) ?? deps.client
await deps.registerOnServer({ channelId, sourceId, tokenHash, expiresAt })
- const watchResult = await deps.client.watchCalendar({
+ const watchResult = await client.watchCalendar({
calendarId,
channelId,
token: plaintextToken,
@@ -81,7 +86,8 @@ export function createGoogleChannelManager(deps: GoogleChannelManagerDeps): Goog
channelId,
resourceId: watchResult.resourceId,
expirationMs: watchResult.expiration ?? expirationMs,
- rotationTimer
+ rotationTimer,
+ client
}
}
@@ -118,7 +124,7 @@ export function createGoogleChannelManager(deps: GoogleChannelManagerDeps): Goog
states.delete(sourceId)
try {
- await deps.client.stopChannel({
+ await state.client.stopChannel({
channelId: state.channelId,
resourceId: state.resourceId
})
diff --git a/apps/desktop/src/main/calendar/google/push-runtime.ts b/apps/desktop/src/main/calendar/google/push-runtime.ts
index 61f06f864..d2f73f496 100644
--- a/apps/desktop/src/main/calendar/google/push-runtime.ts
+++ b/apps/desktop/src/main/calendar/google/push-runtime.ts
@@ -6,6 +6,7 @@ import { createGoogleCalendarClient } from './client'
import { resolveDefaultGoogleAccountId } from './oauth'
import { requireDatabase } from '../../database'
import { createGoogleChannelManager, type GoogleChannelManager } from './google-channel-manager'
+import { getCalendarSourceById } from '../repositories/calendar-sources-repository'
const log = createLogger('Calendar:GooglePushRuntime')
@@ -82,17 +83,33 @@ function isPushFeatureEnabled(): boolean {
return process.env.CALENDAR_PUSH_ENABLED === '1' && resolveHmacKey().length > 0
}
+export function resolvePushAccountIdForSource(
+ sourceId: string,
+ db = requireDatabase()
+): string {
+ const source = getCalendarSourceById(db, sourceId)
+ const accountId = source?.accountId ?? resolveDefaultGoogleAccountId(db)
+ if (!accountId) {
+ throw new Error(`Cannot resolve Google push account for source ${sourceId}`)
+ }
+ return accountId
+}
+
function buildProductionChannelManager(
onActiveCountChange: (count: number) => void
): GoogleChannelManager {
const hmacKey = resolveHmacKey()
- const accountId = resolveDefaultGoogleAccountId(requireDatabase())
- if (!accountId) {
+ const defaultAccountId = resolveDefaultGoogleAccountId(requireDatabase())
+ if (!defaultAccountId) {
throw new Error('Cannot start Google push channel manager without a connected account')
}
return createGoogleChannelManager({
- client: createGoogleCalendarClient({ accountId }),
+ client: createGoogleCalendarClient({ accountId: defaultAccountId }),
+ resolveClient: ({ sourceId }) =>
+ createGoogleCalendarClient({
+ accountId: resolvePushAccountIdForSource(sourceId, requireDatabase())
+ }),
registerOnServer: async (body) => {
const token = await getValidAccessToken()
if (!token) throw new Error('Not signed in — cannot register push channel')
diff --git a/apps/desktop/src/main/calendar/google/sync-service.test.ts b/apps/desktop/src/main/calendar/google/sync-service.test.ts
index d6bf2fc1b..5b69de4f4 100644
--- a/apps/desktop/src/main/calendar/google/sync-service.test.ts
+++ b/apps/desktop/src/main/calendar/google/sync-service.test.ts
@@ -44,11 +44,13 @@ vi.mock('../../sync/auth-state', () => ({
isMemryUserSignedIn: vi.fn(async () => true)
}))
-import { hasGoogleCalendarConnection, hasGoogleCalendarLocalAuth } from './oauth'
+import { hasGoogleCalendarConnection, hasGoogleCalendarLocalAuth, listGoogleAccountIds } from './oauth'
+import * as googleClientModule from './client'
import { isMemryUserSignedIn } from '../../sync/auth-state'
import {
applyGoogleCalendarDelete,
applyGoogleCalendarWriteback,
+ ensureGoogleCalendarSourceSelected,
syncLocalSourceToGoogleCalendar,
pushSourceToGoogleCalendar,
syncGoogleCalendarNow,
@@ -85,7 +87,7 @@ describe('google calendar sync service', () => {
id: 'google-calendar:memry',
provider: 'google',
kind: 'calendar',
- accountId: 'google-account:1',
+ accountId: 'test-account@example.com',
remoteId: 'remote-memry-calendar',
title: 'Memry',
timezone: 'UTC',
@@ -771,7 +773,7 @@ describe('google calendar sync service', () => {
id: 'google-calendar:selected',
provider: 'google',
kind: 'calendar',
- accountId: 'google-account:1',
+ accountId: 'test-account@example.com',
remoteId: 'remote-selected-calendar',
title: 'Personal',
timezone: 'UTC',
@@ -1489,6 +1491,88 @@ describe('google calendar sync service', () => {
}
}
+ it('polls each selected calendar with the owning account client when multiple accounts exist', async () => {
+ const aliceAccountId = 'alice@example.com'
+ const bobAccountId = 'bob@example.com'
+
+ seedGoogleCalendarSource({
+ id: 'google-calendar:alice-work',
+ accountId: aliceAccountId,
+ remoteId: 'alice-work',
+ title: 'Alice Work',
+ isMemryManaged: false
+ })
+ seedGoogleCalendarSource({
+ id: 'google-calendar:bob-work',
+ accountId: bobAccountId,
+ remoteId: 'bob-work',
+ title: 'Bob Work',
+ isMemryManaged: false
+ })
+
+ vi.mocked(listGoogleAccountIds).mockReturnValueOnce([aliceAccountId, bobAccountId])
+
+ const aliceClient = {
+ listCalendars: vi.fn(async () => [
+ {
+ id: 'alice-memry',
+ title: 'Memry',
+ timezone: 'UTC',
+ color: null,
+ isPrimary: false
+ }
+ ]),
+ createCalendar: vi.fn(),
+ listEvents: vi.fn(async () => ({ nextSyncCursor: 'alice-cursor', events: [] }))
+ }
+ const bobClient = {
+ listCalendars: vi.fn(async () => [
+ {
+ id: 'bob-memry',
+ title: 'Memry',
+ timezone: 'UTC',
+ color: null,
+ isPrimary: false
+ }
+ ]),
+ createCalendar: vi.fn(),
+ listEvents: vi.fn(async () => ({ nextSyncCursor: 'bob-cursor', events: [] }))
+ }
+ const clientByAccountId = {
+ [aliceAccountId]: aliceClient,
+ [bobAccountId]: bobClient
+ }
+
+ const clientSpy = vi
+ .spyOn(googleClientModule, 'createGoogleCalendarClient')
+ .mockImplementation(({ accountId }) => clientByAccountId[accountId] as never)
+
+ try {
+ await syncGoogleCalendarNow(db)
+ } finally {
+ clientSpy.mockRestore()
+ }
+
+ expect(aliceClient.listEvents).toHaveBeenCalledWith(
+ expect.objectContaining({ calendarId: 'alice-work' })
+ )
+ expect(bobClient.listEvents).toHaveBeenCalledWith(
+ expect.objectContaining({ calendarId: 'bob-work' })
+ )
+
+ const memrySources = db
+ .select()
+ .from(calendarSources)
+ .all()
+ .filter((source) => source.isMemryManaged)
+ expect(memrySources).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ accountId: aliceAccountId, remoteId: 'alice-memry' }),
+ expect.objectContaining({ accountId: bobAccountId, remoteId: 'bob-memry' })
+ ])
+ )
+ })
+
it('skips all Google API calls when Memry user is not signed in', async () => {
vi.mocked(isMemryUserSignedIn).mockResolvedValue(false)
const client = buildClient()
@@ -1510,6 +1594,35 @@ describe('google calendar sync service', () => {
})
})
+ it('stores the routed accountId when selecting an existing Google calendar source', async () => {
+ seedGoogleCalendarSource({
+ id: 'google-calendar:team',
+ accountId: 'alice@example.com',
+ remoteId: 'team@group.calendar.google.com',
+ title: 'Team',
+ isMemryManaged: false,
+ isSelected: false
+ })
+
+ const client = {
+ listCalendars: vi.fn(async () => [])
+ }
+
+ const saved = await ensureGoogleCalendarSourceSelected(
+ db,
+ client,
+ 'team@group.calendar.google.com',
+ 'bob@example.com'
+ )
+
+ expect(client.listCalendars).not.toHaveBeenCalled()
+ expect(saved).toMatchObject({
+ id: 'google-calendar:team',
+ accountId: 'bob@example.com',
+ isSelected: true
+ })
+ })
+
describe('pushSourceToGoogleCalendar — M2 target calendar resolution', () => {
function buildPushClient() {
return {
@@ -1762,7 +1875,7 @@ describe('google calendar sync service', () => {
id: 'google-calendar:work-preseeded',
provider: 'google',
kind: 'calendar',
- accountId: 'google-account:1',
+ accountId: 'test-account@example.com',
remoteId: 'work@group.calendar.google.com',
title: 'Work',
timezone: 'UTC',
diff --git a/apps/desktop/src/main/calendar/google/sync-service.ts b/apps/desktop/src/main/calendar/google/sync-service.ts
index 4f447e5e5..1df327714 100644
--- a/apps/desktop/src/main/calendar/google/sync-service.ts
+++ b/apps/desktop/src/main/calendar/google/sync-service.ts
@@ -13,7 +13,11 @@ import {
enqueueLocalSyncUpdate
} from '../../sync/local-mutations'
import { publishProjectionEvent } from '../../projections'
-import { hasGoogleCalendarConnection, resolveDefaultGoogleAccountId } from './oauth'
+import {
+ hasGoogleCalendarConnection,
+ listGoogleAccountIds,
+ resolveDefaultGoogleAccountId
+} from './oauth'
import { resolveTargetGoogleAccountId } from './account-routing'
import { loadSourceAsGoogleEvent, pushEventWithConflictRetry } from './push-conflict-retry'
import { isMemryUserSignedIn } from '../../sync/auth-state'
@@ -118,12 +122,13 @@ function getExistingGoogleBinding(
async function ensureMemryCalendarSource(
db: DataDb,
- client: Pick
+ client: Pick,
+ accountId: string
): Promise {
const existing = listCalendarSources(db, {
provider: 'google',
kind: 'calendar'
- }).find((source) => source.isMemryManaged)
+ }).find((source) => source.isMemryManaged && source.accountId === accountId)
if (existing) return existing
@@ -134,7 +139,6 @@ async function ensureMemryCalendarSource(
const localId = `google-calendar:${remote.id}`
const now = getNow()
- const account = listCalendarSources(db, { provider: 'google', kind: 'account' })[0]
const existingSource = getCalendarSourceById(db, localId)
const existed = Boolean(existingSource)
@@ -142,7 +146,7 @@ async function ensureMemryCalendarSource(
id: localId,
provider: 'google',
kind: 'calendar',
- accountId: account?.id ?? null,
+ accountId,
remoteId: remote.id,
title: remote.title,
timezone: remote.timezone ?? LOCAL_TIMEZONE,
@@ -164,11 +168,19 @@ async function ensureMemryCalendarSource(
return saved
}
-function getMemryManagedGoogleSource(db: DataDb): typeof calendarSources.$inferSelect | undefined {
+function getMemryManagedGoogleSource(
+ db: DataDb,
+ accountId?: string
+): typeof calendarSources.$inferSelect | undefined {
return listCalendarSources(db, {
provider: 'google',
kind: 'calendar'
- }).find((source) => source.isMemryManaged && !source.archivedAt)
+ }).find(
+ (source) =>
+ source.isMemryManaged &&
+ !source.archivedAt &&
+ (accountId ? source.accountId === accountId : true)
+ )
}
/**
@@ -182,7 +194,8 @@ function getMemryManagedGoogleSource(db: DataDb): typeof calendarSources.$inferS
export async function ensureGoogleCalendarSourceSelected(
db: DataDb,
client: Pick,
- remoteCalendarId: string
+ remoteCalendarId: string,
+ accountId: string
): Promise {
const existing = listCalendarSources(db, { provider: 'google', kind: 'calendar' }).find(
(source) => source.remoteId === remoteCalendarId && !source.archivedAt
@@ -191,9 +204,10 @@ export async function ensureGoogleCalendarSourceSelected(
const now = getNow()
if (existing) {
- if (existing.isSelected) return existing
+ if (existing.isSelected && existing.accountId === accountId) return existing
const updated = upsertCalendarSource(db, {
...existing,
+ accountId,
isSelected: true,
modifiedAt: now
})
@@ -209,7 +223,6 @@ export async function ensureGoogleCalendarSourceSelected(
return null
}
- const account = listCalendarSources(db, { provider: 'google', kind: 'account' })[0]
const localId = `google-calendar:${remote.id}`
const existingById = getCalendarSourceById(db, localId)
const existed = Boolean(existingById)
@@ -218,7 +231,7 @@ export async function ensureGoogleCalendarSourceSelected(
id: localId,
provider: 'google',
kind: 'calendar',
- accountId: account?.id ?? null,
+ accountId,
remoteId: remote.id,
title: remote.title,
timezone: remote.timezone ?? LOCAL_TIMEZONE,
@@ -323,7 +336,8 @@ async function resolveTargetCalendarId(
db: DataDb,
target: CalendarSyncTarget,
existingBinding: typeof calendarBindings.$inferSelect | undefined,
- client: Pick
+ client: Pick,
+ accountId: string
): Promise {
// Existing binding wins — retargeting a bound event would require
// events.move on Google's side and coordinated etag handling (M3+ work).
@@ -335,20 +349,21 @@ async function resolveTargetCalendarId(
// calendar (Codex M2 review finding 2).
const eventTarget = getEventTargetCalendarId(db, target)
if (eventTarget) {
- await ensureGoogleCalendarSourceSelected(db, client, eventTarget)
+ await ensureGoogleCalendarSourceSelected(db, client, eventTarget, accountId)
return eventTarget
}
// User's onboarding-selected default (covers tasks / reminders / snoozes too).
const { defaultTargetCalendarId } = readCalendarGoogleSettings(db)
if (defaultTargetCalendarId) {
- await ensureGoogleCalendarSourceSelected(db, client, defaultTargetCalendarId)
+ await ensureGoogleCalendarSourceSelected(db, client, defaultTargetCalendarId, accountId)
return defaultTargetCalendarId
}
- // Final fallback: the auto-created Memry calendar.
+ // Final fallback: the auto-created Memry calendar (per the routed account).
const memrySource =
- getMemryManagedGoogleSource(db) ?? (await ensureMemryCalendarSource(db, client))
+ getMemryManagedGoogleSource(db, accountId) ??
+ (await ensureMemryCalendarSource(db, client, accountId))
return memrySource.remoteId
}
@@ -364,8 +379,17 @@ export async function pushSourceToGoogleCalendar(
): Promise {
const existingBinding = getExistingGoogleBinding(db, target)
const routedAccountId = resolveTargetGoogleAccountId(db, target, existingBinding)
+ if (!routedAccountId) {
+ throw new Error('No connected Google account to push to')
+ }
const client = getGoogleClient(db, deps as { client?: GoogleCalendarClient }, routedAccountId)
- const resolvedCalendarId = await resolveTargetCalendarId(db, target, existingBinding, client)
+ const resolvedCalendarId = await resolveTargetCalendarId(
+ db,
+ target,
+ existingBinding,
+ client,
+ routedAccountId
+ )
const now = getNow()
const bindingId =
existingBinding?.id ?? `calendar_binding:google:${target.sourceType}:${target.sourceId}`
@@ -653,7 +677,8 @@ async function syncGoogleCalendarSourceInner(
throw new Error(`Calendar source not found: ${sourceId}`)
}
- const client = getGoogleClient(db, deps as { client?: GoogleCalendarClient })
+ const clientAccountId = source.accountId ?? resolveDefaultGoogleAccountId(db)
+ const client = getGoogleClient(db, deps as { client?: GoogleCalendarClient }, clientAccountId)
const now = getNow()
const isInitialSync = !source.syncCursor
@@ -756,8 +781,16 @@ export async function syncGoogleCalendarNow(
syncInFlight = true
try {
- const client = getGoogleClient(db, deps)
- await ensureMemryCalendarSource(db, client)
+ const accountIds = deps.client
+ ? [resolveDefaultGoogleAccountId(db)].filter((accountId): accountId is string =>
+ Boolean(accountId)
+ )
+ : listGoogleAccountIds(db)
+
+ for (const accountId of accountIds) {
+ const client = getGoogleClient(db, deps, accountId)
+ await ensureMemryCalendarSource(db, client, accountId)
+ }
const sources = listCalendarSources(db, {
provider: 'google',
@@ -766,7 +799,7 @@ export async function syncGoogleCalendarNow(
}).filter((source) => !source.isMemryManaged)
for (const source of sources) {
- await syncGoogleCalendarSource(db, source.id, { client })
+ await syncGoogleCalendarSource(db, source.id, deps)
}
} finally {
syncInFlight = false
diff --git a/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.test.tsx b/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.test.tsx
index b0398fa8d..32c26d4a8 100644
--- a/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.test.tsx
+++ b/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.test.tsx
@@ -286,7 +286,9 @@ describe('Google Calendar integration row', () => {
'Quota exceeded for project 123'
)
- fireEvent.click(screen.getByTestId('calendar-source-retry-google-calendar-work'))
+ fireEvent.pointerDown(screen.getByTestId('calendar-source-retry-google-calendar-work'), {
+ button: 0
+ })
await waitFor(() => {
expect(mockRetryGoogleCalendarSourceSync).toHaveBeenCalledWith({
diff --git a/apps/desktop/src/renderer/src/components/settings/google-calendar-source-picker.tsx b/apps/desktop/src/renderer/src/components/settings/google-calendar-source-picker.tsx
index 57b4132ec..34a0ba93a 100644
--- a/apps/desktop/src/renderer/src/components/settings/google-calendar-source-picker.tsx
+++ b/apps/desktop/src/renderer/src/components/settings/google-calendar-source-picker.tsx
@@ -88,7 +88,16 @@ export function GoogleCalendarSourcePicker({
size="sm"
className="h-6 px-2 text-[10px]/3"
disabled={isRetrying}
- onClick={() => onRetrySource(source.id)}
+ onPointerDown={(e) => {
+ if (e.button !== 0) return
+ if (isRetrying) return
+ e.preventDefault()
+ onRetrySource(source.id)
+ }}
+ onClick={() => {
+ if (isRetrying) return
+ onRetrySource(source.id)
+ }}
data-testid={`calendar-source-retry-${source.id}`}
>
{isRetrying ? 'Retrying…' : 'Retry now'}