import { createHash } from 'node:crypto'; import type { HubSpotObjectType, HubSpotRecord } from './contracts'; export interface HubSpotSyncCursor { after: string | null; phase: 'initial' | 'reconcile'; } export interface HubSpotSyncStore { getCursor(connectionId: string, objectType: HubSpotObjectType): Promise; /** Records and the next cursor must commit in the same transaction. */ commitPage(input: { connectionId: string; objectType: HubSpotObjectType; phase: 'initial' | 'reconcile'; expectedAfter: string | null; nextAfter: string | null; records: readonly HubSpotSyncRecord[]; completedAt: Date; }): Promise; } export interface HubSpotSyncTokenProvider { getAccessToken(connectionId: string, signal?: AbortSignal): Promise; } export interface HubSpotSyncCrmClient { listObjects( accessToken: string, objectType: HubSpotObjectType, options?: { after?: string | null; signal?: AbortSignal }, ): Promise<{ results: HubSpotRecord[]; nextAfter: string | null }>; } export interface HubSpotSyncRecord extends HubSpotRecord { contentHash: string; fetchedAt: Date; } export interface HubSpotSyncPageResult { objectType: HubSpotObjectType; records: number; nextAfter: string | null; complete: boolean; } export class HubSpotSyncService { constructor( private readonly store: HubSpotSyncStore, private readonly tokens: HubSpotSyncTokenProvider, private readonly crm: HubSpotSyncCrmClient, private readonly now: () => Date = () => new Date(), ) {} async syncNextPage( connectionId: string, objectType: HubSpotObjectType, signal?: AbortSignal, ): Promise { const cursor = await this.store.getCursor(connectionId, objectType); const accessToken = await this.tokens.getAccessToken(connectionId, signal); const page = await this.crm.listObjects(accessToken, objectType, { after: cursor.after, signal, }); const fetchedAt = this.now(); const records = page.results.map((record) => ({ ...record, fetchedAt, contentHash: hashHubSpotRecord(record), })); await this.store.commitPage({ connectionId, objectType, phase: cursor.phase, expectedAfter: cursor.after, nextAfter: page.nextAfter, records, completedAt: fetchedAt, }); return { objectType, records: records.length, nextAfter: page.nextAfter, complete: page.nextAfter === null, }; } } export function hashHubSpotRecord(record: HubSpotRecord): string { const properties = Object.fromEntries( Object.entries(record.properties).sort(([left], [right]) => left.localeCompare(right)), ); return createHash('sha256').update(JSON.stringify({ id: record.id, properties, createdAt: record.createdAt, updatedAt: record.updatedAt, archived: record.archived, })).digest('hex'); }