New request
#20424: Marketplace catalog never garbage-collects orphaned applicationRegistration rows
We'll provision a sandbox, run an agent against the issue, and open a draft PR. You can pull the branch and iterate from there.
Marketplace catalog never garbage-collects orphaned applicationRegistration rows
Summary
MarketplaceCatalogSyncService is an upsert-only loop. Once a
(sourcePackage, universalIdentifier) row is written, it persists
forever — even after the UUID disappears from all published versions
of that package, or the package itself is unpublished from npm.
User-facing effect: Twenty's "NPM packages" search shows ghost entries that can't be installed (their version no longer exists on npm) and can't be removed via UI. Operators see two "Vexa" rows in the marketplace search where there should be one.
Versions
- Twenty:
v2.2.0(also present onmainper source links below)
Repro
The trigger is normal in app development: the manifest's
universalIdentifier changes between versions. Reasons it changes
in practice:
- Author rotates the UUID (e.g. to bypass a stale registration during install debugging — exactly what we did)
- A package is republished by a different author with a fresh UUID
- A fork takes over the npm name with a different identity
Steps:
- Publish
@me/my-twenty-app@1.0.0withuniversalIdentifier=A - Run
yarn twenty server catalog-syncagainst any Twenty server → catalog row inserted:(@me/my-twenty-app, A) → 1.0.0 - Publish
@me/my-twenty-app@1.0.1withuniversalIdentifier=B - Run
catalog-syncagain → catalog upsert:(@me/my-twenty-app, B) → 1.0.1→ row for UUIDAis untouched
Inspect the catalog from the workspace metadata API:
{
findMarketplaceAppDetail(universalIdentifier: "<A>") { latestAvailableVersion isListed }
findMarketplaceAppDetail(universalIdentifier: "<B>") { latestAvailableVersion isListed }
}
Both come back, both isListed: true. The user's NPM-packages
search shows two entries for the same sourcePackage. Clicking the
older one's "Install" fails (the version it points at exists on npm
but its manifest's UUID-vs-current-package-state is now an orphan).
Expected
Each (sourcePackage) should have at most one isListed row in the
catalog at a time — the one matching the current latest npm version.
Older UUIDs that no longer appear in any published version should be
either deleted or hidden (isListed=false).
Actual
Catalog accumulates one row per historical UUID per package. No GC.
Source citations
packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace-catalog-sync.service.ts:
private async syncRegistryApps(): Promise<void> {
const packages = await this.marketplaceService.fetchAppsFromRegistry();
// ↑ npm /-/v1/search?text=keywords:twenty-app — returns LATEST per package only
for (const pkg of packages) {
const fetchedManifest = await this.marketplaceService.fetchManifestFromRegistryCdn(
pkg.name, pkg.version,
);
const universalIdentifier = fetchedManifest.application.universalIdentifier;
await this.applicationRegistrationService.upsertFromCatalog({
universalIdentifier,
sourcePackage: pkg.name,
latestAvailableVersion: pkg.version,
...
});
}
// no DELETE / cleanup step
}
packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts upsertFromCatalog:
const existing = await this.findOneByUniversalIdentifier(params.universalIdentifier);
if (isDefined(existing)) {
await this.applicationRegistrationRepository.save({ ...existing, ...params });
} else {
await this.applicationRegistrationRepository.save(repo.create({ ... params, isListed: true }));
}
// no else-side touching other rows; no DELETE branch anywhere
packages/twenty-server/src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.cron.job.ts:
async handle(): Promise<void> {
await this.marketplaceCatalogSyncService.syncCatalog();
// thin wrapper, nothing else
}
A repo-wide grep for ApplicationRegistration + (delete | orphan | prune)
returns only schema migrations, no runtime cleanup.
Suggested fix
Inside syncRegistryApps, after the upsert loop:
const seen = new Map<string, string>(); // sourcePackage → universalIdentifier
for (const pkg of packages) {
// ... existing upsert, also record:
seen.set(pkg.name, manifest.application.universalIdentifier);
}
// Mark stale entries as unlisted (or delete if you want a hard purge)
await this.applicationRegistrationRepository
.createQueryBuilder()
.update()
.set({ isListed: false })
.where('"sourceType" = :type', { type: 'NPM' })
.andWhere('"sourcePackage" IN (:...names)', { names: [...seen.keys()] })
.andWhere(/* universalIdentifier != the seen one for that sourcePackage */)
.execute();
Soft-unlist (isListed=false) is safer than DELETE — preserves any
existing application rows that reference the registration via FK
without causing cascade issues.
Also worth considering: a stale entry whose sourcePackage no longer
appears in keywords:twenty-app search at all (entire package
unpublished from npm) — current code leaves those forever too.
Repro context
- Hit while developing https://github.com/vexa-ai/vexa-twenty-app
- Symptom on
crm.vexa.ai: two@vexaai/twenty-appVexa entries visible in "NPM packages" search after rotating the UUID mid-development - Same root cause as #20423 in spirit — both are points where the marketplace pipeline doesn't behave robustly under realistic app lifecycles.
The marketplace catalog sync service only upserts package rows and never deletes orphaned registrations that no longer exist in npm.
- packages/twenty-server/src/engine/core-modules/marketplace/services/marketplace-catalog-sync.service.ts
- packages/twenty-server/src/engine/core-modules/marketplace/repositories/marketplace-app.repository.ts