···88} from '@octokit/webhooks-types'
99import { verify } from '@octokit/webhooks-methods'
1010import { sql } from 'drizzle-orm'
1111-import { installation, webhookEvent } from '#server/db/schema'
1111+import { installation, job, webhookEvent } from '#server/db/schema'
1212import { kickWorker } from '#server/utils/kick-worker'
1313import { enqueue } from '#server/utils/queue'
1414import { revokeKeysForInstallation } from '#server/utils/tangled-pubkey'
···9191 // Revoke the user's sh.tangled.publicKey PDS records first; the cascade
9292 // below drops the local ssh_key rows that hold the rkeys we need.
9393 await revokeKeysForInstallation(body.installation.id)
9494+ // Cancel any still-pending work for this install. The `job` table has no
9595+ // FK to `installation` (payload carries the id as JSON), so nothing
9696+ // cascades; left alone, these jobs retry to exhaustion failing on the
9797+ // now-missing FK target (e.g. the ssh_key insert). Drop only unstarted
9898+ // work; a running job finishes and no-ops on its own guards.
9999+ await db.delete(job).where(sql`
100100+ ${job.status} in ('queued', 'failed')
101101+ AND (${job.payload}->>'installationId')::bigint = ${body.installation.id}
102102+ `)
94103 // installation row deletion cascades to user_identity, ssh_key, repo_mapping.
95104 await db.delete(installation).where(sql`${installation.id} = ${body.installation.id}`)
96105 }
···11import type { OAuthSession } from '@atproto/oauth-client-node'
22import { and, eq, sql } from 'drizzle-orm'
33-import { repoMapping, userIdentity } from '../db/schema'
33+import { installation, repoMapping, userIdentity } from '../db/schema'
44import { useOAuthClient } from './atproto-oauth'
55import { useDb } from './db'
66import { installationOctokit } from './github-app'
···198198199199 if (envelope.kind === 'atproto.publish-pubkey') {
200200 const { did, installationId, force } = publishPubkeyPayload(envelope.payload)
201201+ // The install may have been uninstalled after this job was enqueued; its
202202+ // row (and any ssh_key FK target) is then gone, so the key insert would
203203+ // fail forever on the foreign key. Drop cleanly instead of retrying, and
204204+ // avoid publishing an orphan sh.tangled.publicKey record to the PDS.
205205+ if (!(await installationExists(installationId))) return
201206 const client = await useOAuthClient()
202207 const session = await client.restore(did)
203208 if (force) await rotateKey({ oauthSession: session, installationId })
···384389 const client = await useOAuthClient()
385390 return client.restore(identity[0]!.did)
386391}
392392+393393+/**
394394+ * Whether the `installation` row still exists. Jobs enqueued before an
395395+ * uninstall can outlive the row (which cascade-deletes user_identity, ssh_key,
396396+ * repo_mapping); handlers use this to drop such orphaned work cleanly rather
397397+ * than failing on a foreign-key violation.
398398+ */
399399+async function installationExists(installationId: number): Promise<boolean> {
400400+ const db = useDb()
401401+ const rows = await db.select({ id: installation.id })
402402+ .from(installation)
403403+ .where(sql`${installation.id} = ${installationId}`)
404404+ .limit(1)
405405+ return rows.length > 0
406406+}