@@ -57,17 +57,23 @@ export async function markRepoAttempted(qx: QueryExecutor, repoId: string): Prom
5757//
5858// Soft-delete: mark every active row stale, then upsert this pass's contacts, reviving whatever
5959// was rediscovered. Readers of this table must filter on deleted_at IS NULL.
60+ //
61+ // merge: skip the soft-delete when this pass ran with a failed extractor — a failed source
62+ // can't wipe contacts it didn't see; stale rows are cleaned on the next fully-successful pass.
6063export async function writeContacts (
6164 qx : QueryExecutor ,
6265 repoId : string ,
6366 contacts : ScoredContact [ ] ,
6467 policies : Partial < RepoPolicies > ,
68+ opts : { merge ?: boolean } = { } ,
6569) : Promise < void > {
6670 await qx . tx ( async ( tx ) => {
67- await tx . result (
68- 'UPDATE security_contacts SET deleted_at = NOW(), updated_at = NOW() WHERE repo_id = $(repoId) AND deleted_at IS NULL' ,
69- { repoId } ,
70- )
71+ if ( ! opts . merge ) {
72+ await tx . result (
73+ 'UPDATE security_contacts SET deleted_at = NOW(), updated_at = NOW() WHERE repo_id = $(repoId) AND deleted_at IS NULL' ,
74+ { repoId } ,
75+ )
76+ }
7177
7278 if ( contacts . length > 0 ) {
7379 await tx . result (
@@ -114,12 +120,12 @@ export async function markReposAttempted(qx: QueryExecutor, repoIds: string[]):
114120 )
115121}
116122
117- type OkResult = Extract < ProcessRepoResult , { status : 'ok' } >
123+ type PersistableResult = Extract < ProcessRepoResult , { status : 'ok' | 'partial ' } >
118124
119125// Bounds blast radius: a bad row only forces a re-extract of its chunk next sweep, not the whole batch.
120126const WRITE_CHUNK_SIZE = 100
121127
122- function prepareBulkPolicyUpdate ( chunk : OkResult [ ] ) : string {
128+ function prepareBulkPolicyUpdate ( chunk : PersistableResult [ ] ) : string {
123129 const rows = chunk . map (
124130 ( _ , i ) =>
125131 `($(id${ i } )::bigint, $(securityPolicyUrl${ i } ), $(vulnerabilityReportingUrl${ i } ), $(pvrResolved${ i } )::boolean, $(bugBountyUrl${ i } ), $(securityTxtUrl${ i } ), $(pvrEnabled${ i } )::boolean)` ,
@@ -156,15 +162,20 @@ function prepareBulkPolicyUpdate(chunk: OkResult[]): string {
156162 )
157163}
158164
159- async function writeContactsChunk ( qx : QueryExecutor , chunk : OkResult [ ] ) : Promise < void > {
165+ async function writeContactsChunk ( qx : QueryExecutor , chunk : PersistableResult [ ] ) : Promise < void > {
160166 if ( chunk . length === 0 ) return
161- const repoIds = chunk . map ( ( r ) => r . repoId )
167+
168+ // Partial repos get a merge-only write: their failed extractor couldn't re-see existing
169+ // contacts, so soft-deleting would wipe data the pass never evaluated.
170+ const fullRefreshIds = chunk . filter ( ( r ) => r . status === 'ok' ) . map ( ( r ) => r . repoId )
162171
163172 await qx . tx ( async ( tx ) => {
164- await tx . result (
165- 'UPDATE security_contacts SET deleted_at = NOW(), updated_at = NOW() WHERE repo_id = ANY($(repoIds)::bigint[]) AND deleted_at IS NULL' ,
166- { repoIds } ,
167- )
173+ if ( fullRefreshIds . length > 0 ) {
174+ await tx . result (
175+ 'UPDATE security_contacts SET deleted_at = NOW(), updated_at = NOW() WHERE repo_id = ANY($(repoIds)::bigint[]) AND deleted_at IS NULL' ,
176+ { repoIds : fullRefreshIds } ,
177+ )
178+ }
168179
169180 const rows = chunk . flatMap ( ( r ) => r . contacts . map ( ( c ) => toContactRow ( r . repoId , c ) ) )
170181 if ( rows . length > 0 ) {
@@ -184,7 +195,9 @@ export async function writeContactsBatch(
184195 const attemptedOnlyIds = outcomes
185196 . filter ( ( o ) => o . status === 'extractor-failed' )
186197 . map ( ( o ) => o . repoId )
187- const ok = outcomes . filter ( ( o ) : o is OkResult => o . status === 'ok' )
198+ const ok = outcomes . filter (
199+ ( o ) : o is PersistableResult => o . status === 'ok' || o . status === 'partial' ,
200+ )
188201
189202 let firstError : Error | undefined
190203 for ( let i = 0 ; i < ok . length ; i += WRITE_CHUNK_SIZE ) {
0 commit comments