43a71aec3c
* fix(supabase): stop server clients leaking a 30s refresh ticker per request
`autoRefreshToken` defaults to true in supabase-js, and off-browser
@supabase/auth-js starts the refresh ticker unconditionally:
// in non-browser environments the refresh token ticker runs always
this.startAutoRefresh()
That is a setInterval firing every 30 s. It calls unref(), so the process
still exits, tests pass, and Vercel never notices because the process is
torn down long before the tickers accumulate. But unref() does not make a
timer collectable: it stays registered in the event loop and remains a GC
root for its callback, which closes over the GoTrueClient, the
SupabaseClient, and the whole request scope around it.
A long-running self-hosted instance therefore leaks one timer plus one
entire request graph (socket, IncomingMessage, ServerResponse, headers,
route context: ~100 kB) per client constructed. One died of "JavaScript
heap out of memory" after 42 h, the last 24 of them completely idle. The
heap snapshot showed 445 retained request graphs and ~1050 Timeouts in
the 30 000 ms bucket, retained via `autoRefreshTicker`, and the rate
matched the traffic exactly: the Docker healthcheck polls /api/health
every 30 s and the webhook dispatch cron runs every minute, so
3 clients/min x 148 min = 444.
- new lib/supabase/service-client.ts: createServiceRoleClient() applies
SERVER_AUTH_OPTIONS, spread LAST so a caller passing its own auth block
cannot re-enable the ticker
- 22 call sites migrated; only booking-templates/sync/cron had ever
passed the options itself
- guard 9 in no-new-antipatterns.mjs fails CI on any new value import of
supabase-js's createClient outside the wrapper; type-only imports are
fine. Verified to fail on a deliberate regression and pass once fixed
- browser clients untouched: a signed-in tab genuinely needs the refresh,
and lib/supabase/client.ts is built on createBrowserClient anyway
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(checks): catch namespace imports in the leaky-supabase-client guard
The guard only matched named imports, so
import * as sb from '@supabase/supabase-js'
sb.createClient(url, key)
reached createClient through member access without ever naming it, and
passed. Verified against the real script before and after: the shape is
flagged now, and `import type * as sb` still passes.
Namespace value imports are treated as leaky outright rather than tracking
member access, which keeps the check a regex over source text with no new
dependency.
Review also suggested excluding *.test.tsx alongside *.test.ts. Skipped: the
repo has no .test.tsx files, and all four sibling checks in this file use
`.test.ts`. Diverging in one of them would read as an accident; if such files
appear, all four should change together.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
367 lines
13 KiB
TypeScript
367 lines
13 KiB
TypeScript
import { createServiceRoleClient } from '@/lib/supabase/service-client'
|
|
import { NextResponse } from 'next/server'
|
|
import { withCronContext } from '@/lib/api/with-cron-context'
|
|
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
|
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
|
import { performSync, saveExtensionData } from '@/extensions/general/cloud-backup/lib/sync'
|
|
import { isScheduleDue } from '@/extensions/general/cloud-backup/lib/schedule'
|
|
import { CLOUD_PROVIDERS } from '@/extensions/general/cloud-backup/lib/provider-registry'
|
|
import {
|
|
sendBackupFailureAlert,
|
|
shouldSendBackupAlert,
|
|
type BackupAlertKind,
|
|
} from '@/extensions/general/cloud-backup/lib/backup-alert'
|
|
import type { CloudStorageProvider } from '@/extensions/general/cloud-backup/lib/cloud-provider'
|
|
import type {
|
|
CloudConnection,
|
|
CloudSchedule,
|
|
} from '@/extensions/general/cloud-backup/types'
|
|
|
|
/**
|
|
* GET /api/extensions/cloud-backup/auto-sync/cron
|
|
*
|
|
* Runs hourly. Finds every (company, provider) pair whose auto-sync is due
|
|
* (daily slot has passed and no attempt has run since it: see `isScheduleDue`)
|
|
* and triggers a full backup for each via the shared `performSync()` helper.
|
|
* Pairs left over when a run hits its time budget stay due and are picked up
|
|
* by the next hourly run instead of losing the day.
|
|
*
|
|
* Providers are scheduled independently: a company can back up to Google Drive
|
|
* nightly and to Dropbox weekly, and a Dropbox failure never marks the Drive
|
|
* backup unhealthy. Each provider keeps its own schedule record, failure
|
|
* counter and alert throttle.
|
|
*
|
|
* Failures increment `consecutive_failures` on that provider's schedule; alert
|
|
* emails go out on dead tokens (once per incident) and repeated failures
|
|
* (threshold in `backup-alert.ts`), throttled per company and provider.
|
|
*
|
|
* Uses the service role client: no user session, no RLS. Each row in
|
|
* `extension_data` carries its own `user_id` (the user who configured the
|
|
* schedule), which we use as the "actor" when writing back the sync result.
|
|
*/
|
|
export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_request, ctx) => {
|
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
|
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
|
|
if (!supabaseUrl || !supabaseServiceKey) {
|
|
return errorResponseFromCode('INTERNAL_ERROR', ctx.log, {
|
|
requestId: ctx.requestId,
|
|
details: { reason: 'Missing Supabase configuration' },
|
|
})
|
|
}
|
|
|
|
const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey)
|
|
const now = new Date()
|
|
const origin = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
|
|
|
// Every provider's schedules are fetched, but only providers this deployment
|
|
// has credentials for are run: without them every sync would fail on the
|
|
// token refresh and burn the failure counter. Schedules belonging to an
|
|
// unconfigured provider are counted and logged rather than dropped silently,
|
|
// because that is a deployment mistake someone needs to see.
|
|
const providerByScheduleKey = new Map<string, CloudStorageProvider>(
|
|
CLOUD_PROVIDERS.map((p) => [p.keys.schedule, p])
|
|
)
|
|
|
|
const { data: rows, error } = await supabase
|
|
.from('extension_data')
|
|
.select('company_id, user_id, key, value')
|
|
.eq('extension_id', 'cloud-backup')
|
|
.in('key', [...providerByScheduleKey.keys()])
|
|
|
|
if (error) {
|
|
ctx.log.error('failed to fetch schedules', error, {
|
|
message: error.message,
|
|
code: error.code,
|
|
})
|
|
return errorResponse(error, ctx.log, { requestId: ctx.requestId })
|
|
}
|
|
|
|
if (!rows || rows.length === 0) {
|
|
return NextResponse.json({ message: 'No schedules configured', processed: 0 })
|
|
}
|
|
|
|
interface Candidate {
|
|
companyId: string
|
|
userId: string
|
|
provider: CloudStorageProvider
|
|
schedule: CloudSchedule
|
|
}
|
|
|
|
const candidates: Candidate[] = []
|
|
const unconfigured = new Map<string, number>()
|
|
for (const row of rows) {
|
|
const provider = providerByScheduleKey.get(row.key as string)
|
|
if (!provider) continue
|
|
const schedule = row.value as CloudSchedule | null
|
|
if (!isScheduleDue(schedule, now)) continue
|
|
if (!provider.isConfigured()) {
|
|
unconfigured.set(provider.id, (unconfigured.get(provider.id) ?? 0) + 1)
|
|
continue
|
|
}
|
|
candidates.push({
|
|
companyId: row.company_id as string,
|
|
userId: row.user_id as string,
|
|
provider,
|
|
schedule: schedule as CloudSchedule,
|
|
})
|
|
}
|
|
|
|
if (unconfigured.size > 0) {
|
|
// Companies are expecting a backup that this deployment cannot perform.
|
|
ctx.log.error(
|
|
'due backups skipped: provider has no OAuth credentials in this environment',
|
|
undefined,
|
|
{ skipped: Object.fromEntries(unconfigured) }
|
|
)
|
|
}
|
|
|
|
if (candidates.length === 0) {
|
|
return NextResponse.json({
|
|
message: 'No companies due this hour',
|
|
checked: rows.length,
|
|
processed: 0,
|
|
})
|
|
}
|
|
|
|
// Connections flagged needs_reauth carry a permanently dead refresh token
|
|
// (the provider returned 400 invalid_grant): skip them instead of retrying
|
|
// every night. They stay visible in the UI until the user reconnects.
|
|
const connectionKeys = [
|
|
...new Set(candidates.map((c) => c.provider.keys.connection)),
|
|
]
|
|
const { data: connectionRows, error: connectionError } = await supabase
|
|
.from('extension_data')
|
|
.select('company_id, key, value')
|
|
.eq('extension_id', 'cloud-backup')
|
|
.in('key', connectionKeys)
|
|
.in('company_id', [...new Set(candidates.map((c) => c.companyId))])
|
|
|
|
if (connectionError) {
|
|
// Fail open: without connection data we cannot tell who needs reauth,
|
|
// so fall back to attempting everyone (performSync re-flags dead tokens).
|
|
ctx.log.warn('failed to fetch connections for reauth check', {
|
|
message: connectionError.message,
|
|
})
|
|
}
|
|
|
|
// Keyed by company + connection key: one company can hold a healthy Drive
|
|
// connection and a dead Dropbox one at the same time.
|
|
const connectionByCompanyAndKey = new Map<string, CloudConnection>()
|
|
for (const r of connectionRows ?? []) {
|
|
const value = r.value as CloudConnection | null
|
|
if (value) connectionByCompanyAndKey.set(`${r.company_id}:${r.key}`, value)
|
|
}
|
|
|
|
const startTime = Date.now()
|
|
const TIME_BUDGET_MS = 250_000 // 4m10s: leaves 50s margin below Vercel's 300s Pro limit
|
|
|
|
const results: {
|
|
companyId: string
|
|
provider: string
|
|
status: 'success' | 'error' | 'skipped'
|
|
error?: string
|
|
}[] = []
|
|
|
|
/**
|
|
* Send a failure alert if warranted and return the new last_alert_at.
|
|
* Best-effort: alert failures are logged inside sendBackupFailureAlert.
|
|
*/
|
|
const maybeAlert = async (params: {
|
|
companyId: string
|
|
userId: string
|
|
providerLabel: string
|
|
kind: BackupAlertKind
|
|
consecutiveFailures: number
|
|
errorMessage: string | null
|
|
lastAlertAt: string | null | undefined
|
|
}): Promise<string | null> => {
|
|
const prior = params.lastAlertAt ?? null
|
|
if (
|
|
!shouldSendBackupAlert({
|
|
kind: params.kind,
|
|
consecutiveFailures: params.consecutiveFailures,
|
|
lastAlertAt: prior,
|
|
now: new Date(),
|
|
})
|
|
) {
|
|
return prior
|
|
}
|
|
const sent = await sendBackupFailureAlert(supabase, {
|
|
companyId: params.companyId,
|
|
userId: params.userId,
|
|
providerLabel: params.providerLabel,
|
|
kind: params.kind,
|
|
consecutiveFailures: params.consecutiveFailures,
|
|
errorMessage: params.errorMessage,
|
|
origin,
|
|
})
|
|
return sent.sent ? new Date().toISOString() : prior
|
|
}
|
|
|
|
for (const candidate of candidates) {
|
|
if (Date.now() - startTime > TIME_BUDGET_MS) {
|
|
ctx.log.info('time budget reached', {
|
|
processedSoFar: results.length,
|
|
skipped: candidates.length - results.length,
|
|
})
|
|
break
|
|
}
|
|
|
|
const { companyId, userId, provider, schedule } = candidate
|
|
const scheduleKey = provider.keys.schedule
|
|
|
|
const connection = connectionByCompanyAndKey.get(
|
|
`${companyId}:${provider.keys.connection}`
|
|
)
|
|
if (connection?.status === 'needs_reauth') {
|
|
// Do not touch last_auto_sync_* here: the schedule keeps showing the
|
|
// failure from the night the dead token was detected. But make sure the
|
|
// incident has been alerted once: a token can go dead via a manual sync
|
|
// (which never emails) and would otherwise stay silent forever.
|
|
const alertedSinceIncident =
|
|
schedule.last_alert_at &&
|
|
connection.needs_reauth_at &&
|
|
new Date(schedule.last_alert_at).getTime() >=
|
|
new Date(connection.needs_reauth_at).getTime()
|
|
if (!alertedSinceIncident) {
|
|
const lastAlertAt = await maybeAlert({
|
|
companyId,
|
|
userId,
|
|
providerLabel: provider.label,
|
|
kind: 'needs_reauth',
|
|
consecutiveFailures: schedule.consecutive_failures ?? 0,
|
|
errorMessage: null,
|
|
lastAlertAt: schedule.last_alert_at,
|
|
})
|
|
if (lastAlertAt !== (schedule.last_alert_at ?? null)) {
|
|
await saveExtensionData(supabase, companyId, userId, scheduleKey, {
|
|
...schedule,
|
|
last_alert_at: lastAlertAt,
|
|
}).catch((persistErr) => {
|
|
ctx.log.error('failed to persist alert state', persistErr as Error, {
|
|
companyId,
|
|
provider: provider.id,
|
|
})
|
|
})
|
|
}
|
|
}
|
|
results.push({
|
|
companyId,
|
|
provider: provider.id,
|
|
status: 'skipped',
|
|
error: 'needs_reauth',
|
|
})
|
|
continue
|
|
}
|
|
|
|
try {
|
|
const syncResult = await performSync({
|
|
supabase,
|
|
companyId,
|
|
userId,
|
|
origin,
|
|
includeDocuments: true,
|
|
allowDocumentFallback: true,
|
|
provider,
|
|
})
|
|
|
|
const consecutiveFailures = syncResult.ok
|
|
? 0
|
|
: (schedule.consecutive_failures ?? 0) + 1
|
|
const safeSyncError = syncResult.ok ? null : getErrorMessage(syncResult.message)
|
|
let lastAlertAt = schedule.last_alert_at ?? null
|
|
if (!syncResult.ok) {
|
|
lastAlertAt = await maybeAlert({
|
|
companyId,
|
|
userId,
|
|
providerLabel: provider.label,
|
|
kind: syncResult.reason === 'needs_reauth' ? 'needs_reauth' : 'repeated_failures',
|
|
consecutiveFailures,
|
|
errorMessage: safeSyncError,
|
|
lastAlertAt,
|
|
})
|
|
}
|
|
|
|
const updated: CloudSchedule = {
|
|
...schedule,
|
|
last_auto_sync_at: new Date().toISOString(),
|
|
last_auto_sync_status: syncResult.ok ? 'success' : 'error',
|
|
last_auto_sync_error: safeSyncError,
|
|
consecutive_failures: consecutiveFailures,
|
|
last_alert_at: lastAlertAt,
|
|
}
|
|
await saveExtensionData(supabase, companyId, userId, scheduleKey, updated)
|
|
|
|
results.push({
|
|
companyId,
|
|
provider: provider.id,
|
|
status: syncResult.ok ? 'success' : 'error',
|
|
error: safeSyncError ?? undefined,
|
|
})
|
|
} catch (err) {
|
|
const safeMessage = getErrorMessage(err)
|
|
ctx.log.error('cloud backup sync failed for company', err as Error, {
|
|
companyId,
|
|
provider: provider.id,
|
|
})
|
|
|
|
const consecutiveFailures = (schedule.consecutive_failures ?? 0) + 1
|
|
const lastAlertAt = await maybeAlert({
|
|
companyId,
|
|
userId,
|
|
providerLabel: provider.label,
|
|
kind: 'repeated_failures',
|
|
consecutiveFailures,
|
|
errorMessage: safeMessage.slice(0, 200),
|
|
lastAlertAt: schedule.last_alert_at,
|
|
})
|
|
|
|
const updated: CloudSchedule = {
|
|
...schedule,
|
|
last_auto_sync_at: new Date().toISOString(),
|
|
last_auto_sync_status: 'error',
|
|
last_auto_sync_error: safeMessage.slice(0, 200),
|
|
consecutive_failures: consecutiveFailures,
|
|
last_alert_at: lastAlertAt,
|
|
}
|
|
await saveExtensionData(supabase, companyId, userId, scheduleKey, updated).catch(
|
|
(persistErr) => {
|
|
ctx.log.error('failed to persist failure state', persistErr as Error, {
|
|
companyId,
|
|
provider: provider.id,
|
|
})
|
|
},
|
|
)
|
|
|
|
results.push({
|
|
companyId,
|
|
provider: provider.id,
|
|
status: 'error',
|
|
error: safeMessage,
|
|
})
|
|
}
|
|
}
|
|
|
|
const successCount = results.filter((r) => r.status === 'success').length
|
|
const errorCount = results.filter((r) => r.status === 'error').length
|
|
const skippedCount = results.filter((r) => r.status === 'skipped').length
|
|
|
|
ctx.log.info('cloud backup cron summary', {
|
|
processed: results.length,
|
|
succeeded: successCount,
|
|
failed: errorCount,
|
|
skipped: skippedCount,
|
|
})
|
|
|
|
return NextResponse.json({
|
|
checked: rows.length,
|
|
candidates: candidates.length,
|
|
processed: results.length,
|
|
successes: successCount,
|
|
errors: errorCount,
|
|
skipped: skippedCount,
|
|
results,
|
|
})
|
|
})
|