github →

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.

Issue
sonarly:mediumApps

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 on main per 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:

  1. Publish @me/my-twenty-app@1.0.0 with universalIdentifier=A
  2. Run yarn twenty server catalog-sync against any Twenty server → catalog row inserted: (@me/my-twenty-app, A) → 1.0.0
  3. Publish @me/my-twenty-app@1.0.1 with universalIdentifier=B
  4. Run catalog-sync again → catalog upsert: (@me/my-twenty-app, B) → 1.0.1 → row for UUID A is 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-app Vexa 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.
Assessmentadvisory
bug●● medium90% confidence

The marketplace catalog sync service only upserts package rows and never deletes orphaned registrations that no longer exist in npm.

Likely files
  • 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
Create the request

This opens a fresh agent run and a draft PR for issue #20424.

Cancel