teqo-backup/src/utilities/googleCalendarSyncHooks.ts
Francisco Solla 6d962ffd9c fix(campaign): C114-LOCK — hooks do sync do Google Calendar seguram row lock por I/O de rede (Closes #870)
Os afterChange/afterDelete de activity e googleCalendarSync rodavam a reconciliacao completa dentro da transacao do save, segurando row lock + pool por todo o I/O do Google (15s por hop, N RTTs -> 45-75s). Corrige com deadline proprio do hook (GOOGLE_CALENDAR_SYNC_HOOK_TIMEOUT_MS=5s) via AbortSignal.timeout combinado com per-hop 15s no cliente (AbortSignal.any) — manual/webhook mantem 15s sem transacao como fallback confiavel. Fail-closed preservado (abort vira paused). Pin unit hook < per-hop + int hook <=7s com fetch lento.
2026-08-24 22:25:23 -03:00

85 lines
2.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'server-only'
import type { Activity, GoogleCalendarSync as GoogleCalendarSyncDoc } from '@/payload-types'
import type { PayloadRequest } from 'payload'
import {
GOOGLE_CALENDAR_SYNC_HOOK_TIMEOUT_MS,
runCampaignCalendarSync,
shouldSyncActivityOperation,
shouldSyncConfigChange,
} from '@/utilities/googleCalendarSync'
/**
* C114 — the two Payload hooks that trigger the Google mirror (in a module of
* their own so tests can mock the engine at the import boundary).
*
* Activity hook (afterChange + afterDelete): delegates the decision to the
* pure guard — task toggles, updates and result records never trigger a pass.
* afterDelete hooks carry no `operation`; its absence means delete.
* Config hook (afterChange on `googleCalendarSync`): D7 auto-resync — a
* changed `calendarId` or a re-enable runs the full reconciliation against
* the new calendar; state-only writes never re-trigger (no loop).
*
* Both NEVER throw: the Teqo write paths are independent of Google
* (fail-closed at the trigger; engine failures land in the `paused` state).
* C114-LOCK: the hooks run INSIDE the save's transaction, so the Google I/O
* is bounded by `GOOGLE_CALENDAR_SYNC_HOOK_TIMEOUT_MS` (S11 parity) — the row
* lock (`activity` / `googleCalendarSync`) is not held for the full
* `REQUEST_TIMEOUT_MS × N` RTTs. The manual / webhook paths keep the full
* per-hop budget (no transaction open) as the reliable fallback.
*/
export const activityGoogleCalendarSyncHook = async ({
doc,
previousDoc,
operation,
req,
}: {
doc: Activity
previousDoc?: Activity
operation?: 'create' | 'update'
req: PayloadRequest
}): Promise<unknown> => {
try {
// C115 — the reverse direction's own write must not re-enter the engine:
// the pass that applied the Google edit is still running; a nested pass
// would only re-list and converge (content equality) at double cost.
if (req.context?.mutationKind === 'googleCalendarSync') return doc
const resolvedOperation = operation ?? 'delete'
if (shouldSyncActivityOperation({ operation: resolvedOperation, doc, previousDoc })) {
await runCampaignCalendarSync(req.payload, {
reason: resolvedOperation,
req,
signal: AbortSignal.timeout(GOOGLE_CALENDAR_SYNC_HOOK_TIMEOUT_MS),
})
}
} catch {
// recorded as paused inside the engine; the write path stays intact
}
return doc
}
export const googleCalendarSyncConfigHook = async ({
doc,
previousDoc,
operation,
req,
}: {
doc: GoogleCalendarSyncDoc
previousDoc?: GoogleCalendarSyncDoc
operation: 'create' | 'update'
req: PayloadRequest
}): Promise<unknown> => {
try {
if (shouldSyncConfigChange({ operation, doc, previousDoc })) {
await runCampaignCalendarSync(req.payload, {
reason: 'config-change',
req,
signal: AbortSignal.timeout(GOOGLE_CALENDAR_SYNC_HOOK_TIMEOUT_MS),
})
}
} catch {
// never throw into admin writes
}
return doc
}