Skip to content

Commit 91c6edc

Browse files
committed
fix: prune stale repo links, reconcile maintainers
Signed-off-by: anilb <epipav@gmail.com>
1 parent 666be44 commit 91c6edc

3 files changed

Lines changed: 66 additions & 42 deletions

File tree

Binary file not shown.

services/apps/packages_worker/src/packagist/upsertPackageInfo.ts

Lines changed: 53 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
1820
export 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
}

services/libs/data-access-layer/src/packages/repos.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,24 @@ export async function getOrCreateRepoByUrl(
3232
return { id: row.id, changedFields: [] }
3333
}
3434

35-
// Removes a package's previous 'declared' repo link when its manifest no longer resolves
36-
// to a trusted repo (the field was removed, or no longer canonicalizes). Only touches
37-
// 'declared' — other sources (deps_dev, heuristic, manual) are owned by different
38-
// pipelines and left alone.
35+
// Removes a package's previous 'declared' repo link(s) when its manifest no longer
36+
// resolves to a trusted repo (the field was removed, or no longer canonicalizes), or
37+
// now resolves to a different one (pass exceptRepoId to keep only that fresh link —
38+
// package_repos' unique key is (package_id, repo_id), not (package_id, source), so a
39+
// plain upsert of the new link never removes a stale one pointing elsewhere). Only
40+
// touches 'declared' — other sources (deps_dev, heuristic, manual) are owned by
41+
// different pipelines and left alone.
3942
export async function removeDeclaredPackageRepo(
4043
qx: QueryExecutor,
4144
packageId: string,
45+
exceptRepoId?: string,
4246
): Promise<string[]> {
4347
const rowCount = await qx.result(
44-
`DELETE FROM package_repos WHERE package_id = $(packageId)::bigint AND source = 'declared'`,
45-
{ packageId },
48+
`DELETE FROM package_repos
49+
WHERE package_id = $(packageId)::bigint
50+
AND source = 'declared'
51+
AND ($(exceptRepoId)::bigint IS NULL OR repo_id <> $(exceptRepoId)::bigint)`,
52+
{ packageId, exceptRepoId: exceptRepoId ?? null },
4653
)
4754
return rowCount > 0 ? ['package_repos.repo_id'] : []
4855
}

0 commit comments

Comments
 (0)