@@ -14,7 +14,9 @@ import type { NormalizedPackagistStats } from './types'
1414
1515// Dynamic-endpoint persistence: packages fields + repo link for ALL packages,
1616// maintainers only for critical ones. Download rows are NOT written here — they
17- // belong to the dedicated downloads-30d/daily lanes.
17+ // belong to the dedicated downloads-30d/daily lanes. All writes share one
18+ // transaction so a failure partway through can never leave the packages row,
19+ // repo link, and maintainer set inconsistent with each other.
1820export async function persistPackagistPackageInfo (
1921 qx : QueryExecutor ,
2022 purl : string ,
@@ -24,51 +26,66 @@ export async function persistPackagistPackageInfo(
2426 // text columns reject; strip them before any field is persisted.
2527 stripNullBytesDeep ( stats )
2628
27- // Step 1: Update packages row
2829 const canonical = stats . repositoryUrl ? canonicalizeRepoUrl ( stats . repositoryUrl ) : null
2930 // Packagist's repository field is free-form/author-supplied. canonicalizeRepoUrl's
3031 // 'other' bucket also matches non-repo URLs (wikis, issue trackers, registry pages)
3132 // that happen to have 2+ path segments, so only trust the verified SCM hosts here —
3233 // github.com/gitlab.com/bitbucket.org — rather than the shared utility's default,
3334 // which other callers (npm/maven/cargo) rely on staying permissive.
3435 const trustedRepo = canonical && canonical . host !== 'other' ? canonical : null
35- const result = await updatePackagistPackageStats ( qx , {
36- purl,
37- description : stats . description ,
38- declaredRepositoryUrl : stats . repositoryUrl ,
39- repositoryUrl : trustedRepo ?. url ?? null ,
40- status : stats . status ,
41- downloadsLast30d : stats . downloadsMonthly ,
42- totalDownloads : stats . downloadsTotal ,
43- dependentCount : stats . dependents ,
44- } )
4536
46- if ( ! result ) {
47- return { found : false , changedFields : [ ] }
48- }
37+ let found = false
38+ const changedFields : string [ ] = [ ]
39+
40+ await qx . tx ( async ( t ) => {
41+ // Step 1: Update packages row
42+ const result = await updatePackagistPackageStats ( t , {
43+ purl,
44+ description : stats . description ,
45+ declaredRepositoryUrl : stats . repositoryUrl ,
46+ repositoryUrl : trustedRepo ?. url ?? null ,
47+ status : stats . status ,
48+ downloadsLast30d : stats . downloadsMonthly ,
49+ totalDownloads : stats . downloadsTotal ,
50+ dependentCount : stats . dependents ,
51+ } )
4952
50- const { id, isCritical } = result
51- const changedFields = [ ...result . changedFields ]
53+ if ( ! result ) return
5254
53- // Step 2: Link the repo for ALL packages — 'declared'/0.8 is the manifest-declared
54- // convention shared by npm/pypi/maven/cargo. When there's no trusted repo (removed
55- // from the manifest, or no longer canonicalizable to a known host), clear any
56- // previously-declared link instead of leaving it pointing at a repo the package no
57- // longer declares.
58- if ( trustedRepo ) {
59- const repo = await getOrCreateRepoByUrl ( qx , trustedRepo . url , trustedRepo . host )
60- const linkChanged = await upsertPackageRepo ( qx , id , repo . id , 'declared' , 0.8 )
61- changedFields . push ( ...repo . changedFields , ...linkChanged )
62- } else {
63- const removedFields = await removeDeclaredPackageRepo ( qx , id )
64- changedFields . push ( ...removedFields )
65- }
55+ found = true
56+ const { id, isCritical } = result
57+ changedFields . push ( ...result . changedFields )
6658
67- // Step 3: Maintainers only for critical packages
68- if ( isCritical && stats . maintainers . length > 0 ) {
69- const maintainerChanges = await upsertPackageMaintainers ( qx , id , stats . maintainers , 'packagist' )
70- changedFields . push ( ...maintainerChanges )
71- }
59+ // Step 2: Link the repo for ALL packages — 'declared'/0.8 is the manifest-declared
60+ // convention shared by npm/pypi/maven/cargo. When there's no trusted repo (removed
61+ // from the manifest, or no longer canonicalizable to a known host), or it now
62+ // resolves to a different repo, clear any previously-declared link that no longer
63+ // applies — package_repos' unique key is (package_id, repo_id), not (package_id,
64+ // source), so upserting the new link alone would leave a stale one dangling.
65+ if ( trustedRepo ) {
66+ const repo = await getOrCreateRepoByUrl ( t , trustedRepo . url , trustedRepo . host )
67+ const linkChanged = await upsertPackageRepo ( t , id , repo . id , 'declared' , 0.8 )
68+ const removedFields = await removeDeclaredPackageRepo ( t , id , repo . id )
69+ changedFields . push ( ...repo . changedFields , ...linkChanged , ...removedFields )
70+ } else {
71+ const removedFields = await removeDeclaredPackageRepo ( t , id )
72+ changedFields . push ( ...removedFields )
73+ }
74+
75+ // Step 3: Maintainers only for critical packages. upsertPackageMaintainers always
76+ // replaces the full stored set (including deleting rows for maintainers no longer
77+ // reported), so it must run even when the registry now reports zero maintainers —
78+ // skipping it on an empty list would leave stale maintainers attached forever.
79+ if ( isCritical ) {
80+ const maintainerChanges = await upsertPackageMaintainers (
81+ t ,
82+ id ,
83+ stats . maintainers ,
84+ 'packagist' ,
85+ )
86+ changedFields . push ( ...maintainerChanges )
87+ }
88+ } )
7289
73- return { found : true , changedFields }
90+ return { found, changedFields }
7491}
0 commit comments