change: make extension versions immutable - #1983
Conversation
|
Supersedes #1866 |
|
The UI changes include various fixes that were existing, for example from the namespace admin page you were redirected to the user settings of the extension where you could not delete them as an admin. Another things was that it was not visible whether a version is active or not, so this is now also included so that as an admin you immediately see that without needing to query the DB. The refactoring also make sure components that are used from the admin dashboard and user settings are now shared in |
|
I decided against an additional enum for the state to keep things as simple as possible as to focus on correctness and making sure we do not add any regression and fix the UI accordingly which was pretty much broken in this regard. In a follow up step we should cleanup the different states an extension can be in, but this will take some time and we need to have the soft-delete as a basis for other work. |
|
re Backend changes: LGTM, one observation: re Frontend changes: Ponytail remark: |
Yes, I wanted exactly to ask this, as perso, I liked more the "enum way" @gnugomez started with. Right now, we have two related boolean columns (there is clearly a logical dependency between the two; maybe even more) but are implemented as independent from each other. |
|
The reason against an enum with state is that you need to have a state machine that ensures what transitions are possible or allowed. We certainly want a state for an extension to also cover cases like:
but this is not something I would do light-heartily so I would like to postpone that to get the immutable version stuff in. |
|
I know that 39 columns is bad, also considering that we use jooq and we need to explicitly select the columns we want to retrieve which is easy to miss. This is certainly an area that needs improvements and the reason why I am afraid to touch that code. |
autumnfound
left a comment
There was a problem hiding this comment.
Java code generally LGTM, can't speak on the React code and I don't have capacity to test immediately. I don't love that we're as light as we are on tests since this is such an impactful change.
…deprecated test container
There was a problem hiding this comment.
issue: I believe we have a bug here, basically when a user asks to delete all versions we run this query, but I see no modifications from you here, we need to make sure that the count we run here is skipping versions that have a remove = true tombstone
otherwise the the extension that bundles the versions trying to be removed will never be removed right?
is this intentional?
There was a problem hiding this comment.
that was missed, ty for spotting.
There was a problem hiding this comment.
It took me a while but I understand the problem now. The bug is that we need to check if this operation (either delete or purge) would remove all active versions of the extension which we would need to additionally check (are there other extensions depending on it).
There was a problem hiding this comment.
at the same time I will also fix #1956 as we actually check if this extension is declared in a bundle and then prevent it from being deleted. Now this is not synchronous with publication as you can publish an extension that declares bundled extensions that do not exist also the IDEs usually handle that gracefully: if you install a bundle with non-existing extensions, they are just skipped.
There was a problem hiding this comment.
also what I meant was more related with purge operations, for soft-deletion I think it's fine since soft-deleting all versions should not delete the root extension, but if we purge we also use isDeleteAllVersions, we should probably create a different one that does the counter properly, otherwise if any version has been soft deleted from an extension the purge is not going to delete the root entity.
but yeah basically what you mentioned above
…xtension should be purged, they are prohibited from uploaded again separately
|
@netomi I like the authority changes you describe at the beginning. That seems like the right approach. |
| // Restrict to namespaces the user is currently a member of: a user who left a namespace must | ||
| // no longer see extensions they published there, even though they remain the publisher. |
There was a problem hiding this comment.
what about the published by field?
There was a problem hiding this comment.
the field is kept as is, the publisher will see his / her own extensions the he published but will not be able to delete them anymore if he is not member of the namespace anymore.
| var namespace = repositories.findNamespace(namespaceName); | ||
| // Only current namespace members may inspect an extension here. A user who left the | ||
| // namespace (or was never a member) has no access, even to versions they published | ||
| // themselves. Members see every version, including ones they did not publish. | ||
| var isOwner = namespace != null && repositories.isNamespaceOwner(user, namespace); | ||
| var isMember = isOwner || (namespace != null && repositories.hasMembership(user, namespace)); | ||
| if (!isMember) { | ||
| var error = "Extension not found: " + NamingUtil.toExtensionId(namespaceName, extensionName); | ||
| throw new ErrorResultException(error, HttpStatus.NOT_FOUND); | ||
| } | ||
|
|
||
| var latest = repositories.findLatestVersion(namespaceName, extensionName, null, false, false); | ||
|
|
||
| ExtensionJson json; | ||
| var latest = repositories.findLatestVersion(user, namespaceName, extensionName); | ||
| if (latest != null) { | ||
| json = local.toExtensionVersionJson(latest, null, false); | ||
| var extension = latest.getExtension(); | ||
| var allVersions = repositories.findTargetPlatformsGroupedByVersion(extension); | ||
| // Annotate each version with whether the caller may delete it, mirroring deleteExtension: | ||
| // owners may delete any version, other members only the versions they published themselves. | ||
| var deletableVersions = isOwner | ||
| ? null | ||
| : repositories.findTargetPlatformsGroupedByVersion(extension, user).stream() | ||
| .map(VersionTargetPlatformsJson::version) | ||
| .collect(Collectors.toSet()); | ||
| json.setAllTargetPlatformVersions( | ||
| repositories.findTargetPlatformsGroupedByVersion(latest.getExtension(), user)); | ||
| json.setActive(latest.getExtension().isActive()); | ||
| allVersions.stream() | ||
| .map( | ||
| v -> v.withCanDelete( | ||
| deletableVersions == null || deletableVersions.contains(v.version()))) | ||
| .toList()); | ||
| json.setActive(extension.isActive()); | ||
| json.setRemoved(latest.isRemoved()); |
There was a problem hiding this comment.
I find that this method now is doing a bit too much, all of the deletable versions dance could be materialized in the UserService instead of keeping it all here.
There was a problem hiding this comment.
Pull request overview
This PR makes extension versions immutable by introducing soft-delete “tombstones” (preventing republish of the same version), adding an admin-only purge path to fully remove versions, updating permission semantics for deletion based on namespace membership, and refactoring the Web UI to reflect deleted/removed states more clearly.
Changes:
- Add soft-delete (
removed) semantics for extension versions (DB + backend + UI), ensuring removed versions can’t be republished. - Add admin purge endpoints and UI affordances for permanently removing versions (freeing identities for republish).
- Refactor namespace/extension list UI components and adjust test infrastructure (shared Postgres container + test tagging).
Reviewed changes
Copilot reviewed 74 out of 76 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| webui/src/pages/user/user-settings-namespaces.tsx | Switch to reusable namespace detail view and provide extension fetch + routing prefix. |
| webui/src/pages/user/user-settings-namespace-detail.tsx | Remove old page-specific namespace detail component (replaced by reusable component). |
| webui/src/pages/user/user-settings-extensions.tsx | Use new card-based extension list component with route prefix. |
| webui/src/pages/user/user-namespace-extension-list.tsx | Remove old namespace extension list container (replaced by reusable component). |
| webui/src/pages/admin-dashboard/use-extension-admin.ts | Add purge mutation hook for admin UI. |
| webui/src/pages/admin-dashboard/publisher-details.tsx | Use new card-based extension list component. |
| webui/src/pages/admin-dashboard/namespace-admin.tsx | Use reusable namespace detail view; move header actions/dialogs into admin page; use admin extension fetch. |
| webui/src/pages/admin-dashboard/extension-admin.tsx | Wire purge functionality into admin extension detail view. |
| webui/src/pages/admin-dashboard/customers/customer-member-list.tsx | Update AddUserDialog import to shared component. |
| webui/src/extension-registry-types.ts | Add removed and canDelete fields to support tombstones and per-version delete permissions. |
| webui/src/extension-registry-service.ts | Add admin purge endpoint client method. |
| webui/src/components/namespace/namespace-member-list.tsx | Rename/relocate namespace member UI components. |
| webui/src/components/namespace/namespace-member-component.tsx | Rename member row component. |
| webui/src/components/namespace/namespace-extension-list.tsx | New reusable namespace extension list with injectable fetch strategy. |
| webui/src/components/namespace/namespace-details.tsx | Rename component + adjust imports/types. |
| webui/src/components/namespace/namespace-detail-view.tsx | New reusable namespace detail view (members/details/extensions + header actions). |
| webui/src/components/namespace/add-namespace-member-dialog.tsx | Update context/dialog imports due to refactor. |
| webui/src/components/extension/extension-version-table.tsx | UI updates for removed versions + purge action column. |
| webui/src/components/extension/extension-version-delete-dialog.tsx | Add delete vs purge modes and improved UX copy. |
| webui/src/components/extension/extension-status-chips.tsx | Display deleted status chip when removed. |
| webui/src/components/extension/extension-detail-view.tsx | Add purge flows + enforce per-version delete permissions in UI. |
| webui/src/components/extension/extension-delete-all-versions-dialog.tsx | Support purge mode and skip removed targets for delete mode. |
| webui/src/components/extension/extension-card-list.tsx | New card list component with route prefix. |
| webui/src/components/extension/extension-card-list-item.tsx | New card item with deleted status rendering + route prefix; icon sizing fix. |
| webui/src/components/add-user-dialog.tsx | Move AddUserDialog to shared component location; fix imports. |
| server/src/test/resources/application.yml | Disable Elasticsearch by default in tests. |
| server/src/test/resources/application-test_search.yml | Enable Elasticsearch only for “test_search” profile. |
| server/src/test/java/org/eclipse/openvsx/UserAPITest.java | Add/adjust tests for membership-derived permissions and soft-delete semantics. |
| server/src/test/java/org/eclipse/openvsx/TestDatabaseConfig.java | Remove per-profile Postgres container config (replaced by shared base class). |
| server/src/test/java/org/eclipse/openvsx/storage/AwsStorageServiceIntegrationTest.java | Tag integration tests + update LocalStack usage. |
| server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java | Move to shared Postgres test base; update renamed repository methods. |
| server/src/test/java/org/eclipse/openvsx/repositories/NamespaceRepositoryTest.java | Move to shared Postgres test base. |
| server/src/test/java/org/eclipse/openvsx/ratelimit/RateLimitIntegrationTest.java | Move to shared Postgres test base. |
| server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionServiceTest.java | New tests for “don’t reactivate removed tombstones” behavior. |
| server/src/test/java/org/eclipse/openvsx/IntegrationTest.java | Move to shared Postgres test base; simplify profiles. |
| server/src/test/java/org/eclipse/openvsx/ExtensionSoftDeleteTest.java | New integration tests covering tombstones, purge, and dependency checks. |
| server/src/test/java/org/eclipse/openvsx/ExtensionServiceTest.java | Add unit test ensuring removed versions aren’t reactivated. |
| server/src/test/java/org/eclipse/openvsx/ExtensionDeleteTest.java | Update semantics: delete keeps tombstone row; move to shared Postgres base. |
| server/src/test/java/org/eclipse/openvsx/extension_control/ExtensionControlServiceTest.java | New tests for replacement selection + cache eviction behavior. |
| server/src/test/java/org/eclipse/openvsx/cache/CacheServiceTest.java | Move to shared Postgres base; adjust delete invocation. |
| server/src/test/java/org/eclipse/openvsx/admin/AdminServiceTest.java | New unit tests for purge cascade across reverse references. |
| server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java | Update expected delete responses and add purge behavior coverage. |
| server/src/test/java/org/eclipse/openvsx/AbstractPostgresContainerTest.java | New shared Postgres Testcontainers base class. |
| server/src/main/resources/db/migration/V1_70__ExtensionVersion_Removed.sql | DB migration adding removed tombstone fields + FK. |
| server/src/main/jooq-gen/** | jOOQ schema updates for removed fields and FK. |
| server/src/main/java/org/eclipse/openvsx/UserAPI.java | Membership-derived access; add per-version canDelete + removed flags. |
| server/src/main/java/org/eclipse/openvsx/search/SimilarityCheckService.java | Treat any existing version (active or not) as “not new” for similarity checks. |
| server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java | Adjust version queries to active-only where needed; rename delete-all logic to active-only. |
| server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionRepository.java | Enforce active-only methods for public version listings. |
| server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionJooqRepository.java | Carry removed through mappings; exclude tombstones from download URL map; active-only delete-all checks. |
| server/src/main/java/org/eclipse/openvsx/repositories/ExtensionJooqRepository.java | Return all extensions for namespace URLs; add “all extension names” query. |
| server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionService.java | Prevent scan completion from reactivating removed tombstones; log and return. |
| server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java | Improve “already published” message for removed (tombstone) versions. |
| server/src/main/java/org/eclipse/openvsx/mirror/** | Mirror now purges dropped upstream versions/extensions (no tombstones) and modernizes types. |
| server/src/main/java/org/eclipse/openvsx/migration/** | Skip tombstones in signature generation; purge (not soft-delete) in target-platform fix migration. |
| server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java | Allow admin to list inactive/soft-deleted extensions in namespace JSON. |
| server/src/main/java/org/eclipse/openvsx/json/VersionTargetPlatformsJson.java | Add optional canDelete for user settings view (omitted elsewhere). |
| server/src/main/java/org/eclipse/openvsx/json/TargetPlatformActiveJson.java | Add removed flag per target platform entry. |
| server/src/main/java/org/eclipse/openvsx/json/NamespaceJson.java | Remove deprecated access field. |
| server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java | Add hidden removed flag. |
| server/src/main/java/org/eclipse/openvsx/ExtensionService.java | Implement soft-delete vs purge semantics; ensure removed tombstones aren’t reactivated. |
| server/src/main/java/org/eclipse/openvsx/extension_control/ExtensionControlService.java | Prevent inactive replacements; evict caches when replacement changes. |
| server/src/main/java/org/eclipse/openvsx/extension_control/ExtensionControlJobRequestHandler.java | Use purge-based admin operations for malicious extension cleanup. |
| server/src/main/java/org/eclipse/openvsx/entities/ExtensionVersion.java | Add tombstone fields (removed, timestamp, removedBy) and equality updates. |
| server/src/main/java/org/eclipse/openvsx/admin/AdminService.java | Replace delete-with-deps with purge reverse-reference cascade; surface removed flag in user publish info. |
| server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java | Add purge endpoints + include inactive extensions in admin namespace view; surface removed flag. |
| server/config/eclipse-java-formatter.xml | Disable comment formatting in Eclipse formatter profile. |
| server/build.gradle | Switch unit/integration test selection to tag-based filtering. |
| .gitignore | Ignore .claude/ directory. |
Files not reviewed (1)
- server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Keys.java: Generated file
Comments suppressed due to low confidence (2)
webui/src/components/extension/extension-card-list-item.tsx:155
canDeleteis part of the props but is never read in this component, so callers may think it affects behavior when it doesn't. Either wire it into the UI (e.g., show/hide delete affordances) or remove the prop and its plumbing to avoid dead API surface.
server/src/main/java/org/eclipse/openvsx/UserAPI.java:455- Same issue as in
/user/extensions: deriving extension-levelremovedfromlatest.isRemoved()can mark an extension as deleted even if the extension still has other active target-platform versions. Gateremovedon the extension being inactive to avoid false positives.
json.setActive(extension.isActive());
json.setRemoved(latest.isRemoved());
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| <TableRow> | ||
| <TableCell width={'auto'} sx={{ whiteSpace: 'nowrap' }}> | ||
| Version | ||
| </TableCell> | ||
| <TableCell>Target Platforms</TableCell> | ||
| <TableCell /> | ||
| </TableRow> |
| var json = latest.toExtensionJson(); | ||
| json.setPreview(latest.isPreview()); | ||
| json.setActive(latest.getExtension().isActive()); | ||
| json.setRemoved(latest.isRemoved()); | ||
| json.setFiles(fileUrls.get(latest.getId())); |
| json.setAllTargetPlatformVersions( | ||
| repositories.findTargetPlatformsGroupedByVersion(latest.getExtension())); | ||
| json.setActive(latest.getExtension().isActive()); | ||
| json.setRemoved(latest.isRemoved()); |
| * If {@code checkDependencies} is {@code true} and this extension is referenced by a bundle or used | ||
| * as a dependency, the delete operation will fail. | ||
| * as a dependency, the operation will fail. | ||
| * | ||
| * @param user the user that will be used for logging the operation | ||
| * @param extension the extension to delete | ||
| * @param checkDependencies whether to check if this extension is still references by bundles or as a dependency | ||
| * @param extension the extension to purge |
| ThreadLocalJobContext.getJobContext().logger().info("deleting " + extensionId); | ||
| try { | ||
| var namespace = extension.getNamespace(); | ||
| admin.deleteExtensionNoWait(mirrorUser, namespace.getName(), extension.getName()); | ||
| extensions.purgeExtension(mirrorUser, extension, false); | ||
| } catch (ErrorResultException e) { |
| public void deleteExtensionVersion(ExtensionVersion extVersion, UserData user) { | ||
| var extension = extVersion.getExtension(); | ||
| admin.deleteExtensionNoWait( | ||
| user, | ||
| extension.getNamespace().getName(), | ||
| extension.getName(), | ||
| TargetPlatformVersion.of(extVersion.getTargetPlatform(), extVersion.getVersion())); | ||
| // The mirror must not keep tombstones: versions dropped upstream are purged so the mirror can | ||
| // re-track upstream state (and re-publish a version that later reappears upstream). | ||
| extensions.purgeExtensionVersion(user, extVersion); | ||
| } |
| public ResultJson purgeExtensionVersion(UserData user, ExtensionVersion extVersion) { | ||
| var extension = extVersion.getExtension(); | ||
| removeExtensionVersion(extVersion); | ||
| extension.getVersions().remove(extVersion); | ||
| updateExtension(extension); |


This PR modifies the default behavior of the registry such that extension versions are immutable.
Deleting an extension version does not remove it from the database, but instead marks is as deleted so that the same version can not be published again. Extension files uploaded to the cloud storage are deleted, so it is not possible to
undeletea version again.Additionally, there is now a purge mechanism for admins to be able to remove extension versions completely from the database as well.
The UI has also been refactored to better display the status of extension versions.
This fixes #1805, #1956.
Additionally the general permission system for deleting an extension has been overhauled. Permission to view / delete extensions is now derived from the namespace membership instead of who published the specific extension version:
Publishers that published a version but are not member of the namespace anymore, will not see that extension anymore and also can not delete it.