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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions apps/desktop/src/main/calendar/provider/adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import type {
CalendarAttendee,
CalendarConferenceData,
CalendarReminders,
CalendarVisibility
} from '@memry/db-schema/schema/calendar-events'
import type { CalendarSyncSourceType } from '../types'

/**
* How a provider tells us "what changed since last time".
*
* - `sync-token` — Google: opaque token returned with each page
* - `delta-link` — Microsoft Graph: `@odata.deltaLink`
* - `sync-collection` — CalDAV RFC 6578
* - `ctag-etag` — CalDAV without RFC 6578: collection ctag, then per-item etags
* - `conditional-get` — plain HTTP `ETag` / `Last-Modified` on a whole feed (ICS)
* - `full` — no incremental support; every pass re-reads everything
*/
export type ProviderIncrementalMode =
'sync-token' | 'delta-link' | 'sync-collection' | 'ctag-etag' | 'conditional-get' | 'full'

/** Which connect flow the UI shell has to render for this provider. */
export type ProviderAuthFlow = 'oauth2' | 'basic' | 'url' | 'none'

export interface ProviderCapabilities {
/** False for read-only providers (ICS). The engine — not the adapter — refuses writes. */
supportsWrite: boolean
/** Can we provision our own "memrynote" calendar on the remote? */
supportsCreateCalendar: boolean
/** Real-time change notifications. False means the runner polls. */
supportsPush: boolean
/** More than one connected account per provider. */
supportsMultiAccount: boolean
incrementalMode: ProviderIncrementalMode
authFlow: ProviderAuthFlow
}

export interface RemoteCalendarDescriptor {
id: string
title: string
timezone: string | null
color: string | null
isPrimary: boolean
}

/**
* One event as the provider reports it. Field names are carried over verbatim
* from the Google-era shape so the mappers and the `calendar_external_events`
* mirror keep working untouched; `raw` stays the provider's own payload.
*/
export interface RemoteCalendarEvent {
id: string
calendarId: string
title: string
description: string | null
location: string | null
startAt: string
endAt: string | null
isAllDay: boolean
timezone: string
status: 'confirmed' | 'tentative' | 'cancelled'
etag: string | null
updatedAt: string | null
attendees: CalendarAttendee[] | null
reminders: CalendarReminders | null
visibility: CalendarVisibility | null
colorId: string | null
conferenceData: CalendarConferenceData | null
recurringEventId: string | null
originalStartTime: string | null
raw: Record<string, unknown>
}

export interface UpsertRemoteEventInput {
sourceType: CalendarSyncSourceType
sourceId: string
title: string
description: string | null
location: string | null
startAt: string
endAt: string | null
isAllDay: boolean
timezone: string
recurrence: string[] | null
attendees?: CalendarAttendee[] | null
reminders?: CalendarReminders | null
visibility?: CalendarVisibility | null
colorId?: string | null
conferenceData?: CalendarConferenceData | null
recurringEventId?: string | null
originalStartTime?: string | null
}

export interface ListRemoteEventsInput {
calendarId: string
/** Whatever the provider's `incrementalMode` calls a cursor, opaque to us. */
syncCursor?: string | null
timeMin?: string | null
timeMax?: string | null
}

export interface ListRemoteEventsResult {
events: RemoteCalendarEvent[]
nextSyncCursor: string | null
}

export interface WatchCalendarInput {
calendarId: string
channelId: string
token: string
webhookUrl: string
ttlSeconds: number
}

export interface WatchCalendarResult {
resourceId: string
expiration: number
}

/**
* The one surface the calendar sync engine is allowed to talk to.
*
* Optional members map one-to-one onto `ProviderCapabilities`: a provider with
* `supportsWrite: false` omits `upsertEvent`/`deleteEvent`, `supportsPush:
* false` omits `watch`/`unwatch`, `supportsCreateCalendar: false` omits
* `createCalendar`.
*/
export interface CalendarProviderAdapter {
listCalendars(): Promise<RemoteCalendarDescriptor[]>
createCalendar?(input: { title: string; timezone: string }): Promise<RemoteCalendarDescriptor>
listEvents(input: ListRemoteEventsInput): Promise<ListRemoteEventsResult>
getEvent(input: { calendarId: string; eventId: string }): Promise<RemoteCalendarEvent>
upsertEvent?(input: {
calendarId: string
eventId: string | null
event: UpsertRemoteEventInput
ifMatch?: string | null
}): Promise<RemoteCalendarEvent>
deleteEvent?(input: { calendarId: string; eventId: string }): Promise<void>
watch?(input: WatchCalendarInput): Promise<WatchCalendarResult>
unwatch?(input: { channelId: string; resourceId: string }): Promise<void>
}
57 changes: 57 additions & 0 deletions apps/desktop/src/main/calendar/provider/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import {
ProviderAuthError,
ProviderConflictError,
ProviderError,
ProviderGoneError,
ProviderRateLimitError,
ProviderTransientError
} from './errors'

describe('calendar provider error taxonomy', () => {
it('every provider error is an Error, a ProviderError, and reports its own class name', () => {
const cases = [
new ProviderAuthError('reconnect'),
new ProviderGoneError('cursor dead'),
new ProviderConflictError('etag mismatch'),
new ProviderRateLimitError('slow down'),
new ProviderTransientError('network blip')
]

for (const error of cases) {
expect(error).toBeInstanceOf(Error)
expect(error).toBeInstanceOf(ProviderError)
expect(error.name).toBe(error.constructor.name)
}
})

it('discriminates by class so the engine can branch on the condition', () => {
const gone: ProviderError = new ProviderGoneError('cursor dead')

expect(gone).toBeInstanceOf(ProviderGoneError)
expect(gone).not.toBeInstanceOf(ProviderAuthError)
expect(gone).not.toBeInstanceOf(ProviderConflictError)
})

it('carries the provider id and the original cause when the adapter supplies them', () => {
const cause = new Error('HTTP 410')
const error = new ProviderGoneError('cursor dead', { providerId: 'google', cause })

expect(error.providerId).toBe('google')
expect(error.cause).toBe(cause)
})

it('leaves providerId and cause undefined when the adapter omits them', () => {
const error = new ProviderAuthError('reconnect')

expect(error.providerId).toBeUndefined()
expect(error.cause).toBeUndefined()
})

it('keeps the rate-limit retry hint, and reads null when the provider gave none', () => {
expect(new ProviderRateLimitError('slow down', { retryAfterMs: 30_000 }).retryAfterMs).toBe(
30_000
)
expect(new ProviderRateLimitError('slow down').retryAfterMs).toBeNull()
})
})
54 changes: 54 additions & 0 deletions apps/desktop/src/main/calendar/provider/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Provider-neutral calendar error taxonomy.
*
* Today the sync engine sniffs Google's wire shape directly (`status === 410`
* for an invalidated syncToken, `status === 412` for an etag mismatch). Every
* other protocol we are about to speak reports the same conditions differently
* — CalDAV answers 412 on a stale ETag but 404/`valid-sync-token` on a dead
* sync-collection token, Microsoft Graph hands back `resyncRequired` in a JSON
* body. Adapters translate their own wire errors into these classes so the
* engine can react to the *condition* rather than to one vendor's status code.
*/

export abstract class ProviderError extends Error {
/** The provider that raised it, when the adapter knows its own id. */
readonly providerId?: string

constructor(message: string, options?: { providerId?: string; cause?: unknown }) {
super(message, options?.cause !== undefined ? { cause: options.cause } : undefined)
this.name = new.target.name
this.providerId = options?.providerId
}
}

/**
* Credentials are gone or no longer accepted. Drives the `reconnect_required`
* account status — the user has to re-authorize, retrying will not help.
*/
export class ProviderAuthError extends ProviderError {}

/**
* The incremental cursor the provider gave us is no longer valid (Google 410,
* CalDAV `valid-sync-token`, Graph `resyncRequired`). The engine clears
* `sync_cursor` and re-runs the source from scratch.
*/
export class ProviderGoneError extends ProviderError {}

/** The remote copy moved under us — HTTP 412 / ETag mismatch. */
export class ProviderConflictError extends ProviderError {}

/** Throttled. `retryAfterMs` is the provider's own hint when it gave one. */
export class ProviderRateLimitError extends ProviderError {
readonly retryAfterMs: number | null

constructor(
message: string,
options?: { retryAfterMs?: number | null; providerId?: string; cause?: unknown }
) {
super(message, options)
this.retryAfterMs = options?.retryAfterMs ?? null
}
}

/** Network blip, 5xx, timeout — worth retrying on the next pass, nothing to report. */
export class ProviderTransientError extends ProviderError {}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ 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 type { DataDb } from '../../../database/types'
import type { CalendarSyncTarget } from '../../types'
import { resolveDefaultGoogleAccountId } from './oauth'

function findAccountIdForCalendarRemoteId(db: DataDb, remoteCalendarId: string): string | null {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import {
CALENDAR_GOOGLE_SETTINGS_DEFAULTS,
type CalendarGoogleSettings
} from '@memry/contracts/settings-schemas'
import { getSetting, setSetting } from '../../settings/settings-store'
import type { DataDb } from '../../database'
import { getSetting, setSetting } from '../../../settings/settings-store'
import type { DataDb } from '../../../database'

const CALENDAR_GOOGLE_SETTINGS_KEY = 'calendar.google'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ vi.mock('./oauth', () => ({
resolveDefaultGoogleAccountId: vi.fn(() => 'work@example.com')
}))

vi.mock('../../sync/auth-state', () => ({
vi.mock('../../../sync/auth-state', () => ({
isMemryUserSignedIn: vi.fn(async () => true)
}))

import { discoverGoogleCalendarSources } from './sync-service'
import { upsertCalendarSource } from '../repositories/calendar-sources-repository'
import { upsertCalendarSource } from '../../repositories/calendar-sources-repository'

const REMOTE_CALENDARS = [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const { loggerMock } = vi.hoisted(() => ({
}
}))

vi.mock('../../lib/logger', () => ({
vi.mock('../../../lib/logger', () => ({
createLogger: () => loggerMock
}))

Expand All @@ -33,7 +33,7 @@ vi.mock('../../lib/logger', () => ({
// API/token failure below throws 'main-process i18n not initialized' instead of
// the mapped message — and throwCalendarApiFailure never attaches
// error.status / error.apiStatus.
vi.mock('../../lib/main-i18n', () => ({
vi.mock('../../../lib/main-i18n', () => ({
getMainI18n: () => ({
t: (key: string) => key,
getFixedT: () => (key: string) => key
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { z } from 'zod'
import { createLogger } from '../../lib/logger'
import { createLogger } from '../../../lib/logger'
import {
clearGoogleCalendarTokens,
getGoogleCalendarTokens,
Expand All @@ -11,7 +11,7 @@ import type {
GoogleCalendarDescriptor,
GoogleCalendarRemoteEvent,
GoogleCalendarUpsertEventInput
} from '../types'
} from '../../types'

const log = createLogger('Calendar:GoogleClient')
const GOOGLE_API_BASE = 'https://www.googleapis.com/calendar/v3'
Expand Down Expand Up @@ -465,7 +465,7 @@ export function createGoogleCalendarClient(
if (!accountId || !accountId.trim()) {
throw new Error('createGoogleCalendarClient requires a non-empty accountId')
}
return {
const client: Omit<GoogleCalendarClient, 'watch' | 'unwatch'> = {
async listCalendars(): Promise<GoogleCalendarDescriptor[]> {
const response = await withAuthorizedResponse(accountId, {
path: '/users/me/calendarList'
Expand Down Expand Up @@ -659,4 +659,14 @@ export function createGoogleCalendarClient(
}
}
}

// `watch`/`unwatch` are the neutral adapter names; `watchCalendar`/
// `stopChannel` are the Google-named pair the push-channel manager and the
// sync-server relay still speak. Same implementation, two entry points,
// until the relay is generalized (#1404).
return {
...client,
watch: client.watchCalendar,
unwatch: client.stopChannel
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const { loggerMock } = vi.hoisted(() => ({
}
}))

vi.mock('../../lib/logger', () => ({
vi.mock('../../../lib/logger', () => ({
createLogger: () => loggerMock
}))

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createLogger } from '../../lib/logger'
import type { GoogleCalendarClient } from '../types'
import { createLogger } from '../../../lib/logger'
import type { GoogleCalendarClient } from '../../types'

const log = createLogger('Calendar:GoogleChannelManager')

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ vi.mock('./oauth', () => ({
resolveDefaultGoogleAccountId: vi.fn(() => null)
}))

vi.mock('../../sync/auth-state', () => ({
vi.mock('../../../sync/auth-state', () => ({
isMemryUserSignedIn: signedInMock
}))

vi.mock('../../database', () => ({
vi.mock('../../../database', () => ({
requireDatabase: vi.fn(() => mockDbHolder.db),
getDatabase: vi.fn(() => mockDbHolder.db),
isDatabaseInitialized: vi.fn(() => true)
Expand Down
Loading
Loading