Typed Java extension points: @ExtensionPoint + @Extension(target = X.class)#6009
Merged
Conversation
…class) Java @extension previously persisted contributions against an opaque extension-point string (`to = "..."`). Consumers had to either look up extensions via the legacy `ExtensionService.findByExtensionPoint(...)` path and re-instantiate by FQN with reflection, or accept a `Map<String, Object>` payload. Neither is type-safe; both throw at runtime when the impl shape and the consumer's expectation drift apart. This change makes the typed flow the only supported Java surface: - @extension drops `String to()`; the new (required) `Class<?> target()` names the extension-point interface the class implements. - NEW @ExtensionPoint annotation marks an interface as the typed contract. Used purely as metadata + documentation; the contract IS the interface methods. - ExtensionClassConsumer (engine-java) reads `target.getName()` as the persisted extension-point key and validates `target.isAssignableFrom(annotatedClass)` at registration. A class declaring @extension(target = X) that doesn't actually implement X is logged and skipped, so a downstream Extensions.find(X.class) can never receive an instance that fails its cast. - NEW Extensions.find(Class<T>) / findFirst(Class<T>) — discover impls, load each from the active ClientClassLoader, validate isAssignableFrom defensively, instantiate via public no-arg ctor, cast to T, return. Callers never reflect. - Cross-runtime extension points (TS / JS contributions to the same logical point) stay expressible only through the existing string- keyed Extensions.getExtensions(String) — a JS module cannot satisfy a Java interface contract, so the typed lookup correctly does not surface JS rows. - README adds a "Typed extension points" section with the contract shape, the FQN-as-identifier caveat, and the cross-runtime note. The sample at dirigiblelabs/sample-java-extension-decorator was updated in lockstep (SampleExtensionPoint interface, SampleContribution implements it, ExtensionConsumer uses Extensions.find(...)). The matching JavaExtensionDecoratorSampleProjectIT now asserts on the typed consumer's response shape. Verified locally with `mvn -P unit-tests clean install` (BUILD SUCCESS, 6:22) and JavaEngineIT (4/4 pass). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…state The original detection in PR #6003 only treated absent DATABASECHANGELOG as the trigger for changeLogSync(). The integration suite hit a separate state that wasn't covered: between IT classes, DirigibleCleaner runs H2 `DROP ALL OBJECTS` against SystemDB, which drops every schema object INCLUDING DATABASECHANGELOG. If the next test boots a fresh Liquibase against a DB where some artefact tables survived (e.g. an orphan write from a different connection during the drop), Liquibase re-creates DATABASECHANGELOG empty, then tries to run create-DIRIGIBLE_BPMN — "Table DIRIGIBLE_BPMN already exists" — and every following IT cascade- fails on context-load. See run 27275559364/job/80611992574: every test after LocalNativeAppLifecycleIT errored out 0.001s. The fix is one extra arm in the bootstrap-state probe: if the sentinel table is present AND DATABASECHANGELOG exists but is empty, run changeLogSync() the same way as for the legacy production upgrade arm. A fresh DB still skips the sync and runs the normal update; a populated DATABASECHANGELOG still proceeds via the normal incremental update. Hibernate's downstream hbm2ddl=update fills in any genuinely-missing tables on the rare path where the orphans are incomplete. CLAUDE.md updated to document both arms. Verified locally with JavaEngineIT (4/4 pass). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous commit handled "tables exist but DATABASECHANGELOG is empty" but the IT suite then surfaced a separate concurrency race in DatabaseFacadeIT (which carries @DirtiesContext(AFTER_CLASS)): [pool-144857-thread-1] Creating database changelog table with name: PUBLIC.DATABASECHANGELOG [main] Creating database changelog table with name: PUBLIC.DATABASECHANGELOG Same millisecond, two threads. Spring's @DirtiesContext tears down the old context but background threads (Quartz workers, synchronization-watcher threads spun by the previous test) outlive the context refresh. The new test boots its own SpringLiquibase while a lingering Liquibase init from the old context is still in its DATABASECHANGELOG bootstrap. Liquibase's own DATABASECHANGELOGLOCK only protects work AFTER both tables exist; it can't serialize the creation of the lock table itself. The losing thread fails with `Table "DATABASECHANGELOG" already exists`, every test class after it that uses the same SystemDB cascade-fails on context load. Fix: a JVM-wide `synchronized (BOOTSTRAP_LOCK)` around performUpdate in LegacyAwareSpringLiquibase. The lock is a private static Object on the class — every SpringLiquibase instance (regardless of which Spring context it belongs to) goes through the same monitor. Only one Liquibase ever runs at a time across the JVM. Cost is "serial Liquibase init", which is fine: each Liquibase only runs once per Spring context boot, and 131-changeset apply takes <2s on H2. CLAUDE.md updated with a "BOOTSTRAP_LOCK — why performUpdate is synchronized" paragraph next to the legacy-deployment section. Verified locally with JavaEngineIT (BUILD SUCCESS). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
delchev
added a commit
to dirigiblelabs/sample-java-entity-decorators
that referenced
this pull request
Jun 11, 2026
Pairs with the platform change in eclipse-dirigible/dirigible#6009. The Java @extension annotation no longer accepts `to = "<string>"` — it requires `target = X.class` referring to an interface marked @ExtensionPoint. After that PR landed on master, this sample failed to compile and broke JavaEntityDecoratorsSampleProjectIT across all three DB variants of integration-tests. - NEW SampleExtensionPoint interface annotated with @ExtensionPoint - SampleContribution implements it; @extension(target = SampleExtensionPoint.class) - ExtensionConsumer calls Extensions.find(SampleExtensionPoint.class) and invokes describe() through the interface; no BeanProvider, no ExtensionService, no Map round-trip. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces the opaque-string
@Extension(to = "...")with a typed@Extension(target = X.class)paired with a new@ExtensionPointmarker on the target interface. Consumers retrieve registered implementations throughExtensions.find(Class<T>)— they get back instances already cast to the interface type and can invoke methods directly, with no reflection and noMap<String, Object>payloads.What changes
sdk.extensions.Extension— dropsString to(); introduces (required)Class<?> target()and an optionalString name()for UI/labelling. The string-keyed variant is intentionally not retained on the Java surface; cross-runtime extension points stay expressible via the TypeScript@Extensiondecorator and the existingExtensions.getExtensions(String)enumeration.sdk.extensions.ExtensionPoint— interface-targeted marker. Metadata + documentation; the contract IS the interface methods.Extensions.find(Class<T>)/Extensions.findFirst(Class<T>)— discovers extension rows by interface FQN, loads each impl from the activeClientClassLoader, validatesisAssignableFromdefensively, instantiates via the public no-arg constructor, casts toT, returns.engine-java/ExtensionClassConsumer— usestarget.getName()as the persisted extension-point key; rejects (logs + skips) any@Extensionclass that does not actually implement its declared target, so a downstreamExtensions.find(...)can never receive an instance that fails the cast.api-modules-javadepends oncore-java(forClientClassLoaderHolder, used to resolve impl classes regardless of where the consumer's interface lives).JavaExtensionDecoratorSampleProjectITbody assertion updated to match the new typed consumer's response shape ("Hello from SampleContribution!").Companion change
Sample repo updated in lockstep — already merged so this PR's IT clones the new sample:
dirigiblelabs/sample-java-extension-decorator#2— addsSampleExtensionPointinterface,SampleContribution implementsit,ExtensionConsumerusesExtensions.find(...).Why the typed-only Java surface
Reviewed during design with the maintainer; the only candidate use case for retaining a string-keyed
@Extension(to = "x")on the Java side was cross-runtime extension points where TS/JS modules also contribute. That case is correctly served by keeping the TS@Extensiondecorator string-keyed and exposingExtensions.getExtensions(String)for enumeration — a JS module can't safely satisfy a Java interface contract. Everything else (loose coupling, evolution flexibility, metadata-only contributions) is either better expressed with a type (interface default methods) or already covered by the existing TS/JS path.Caveat to land in code review
The extension-point's persisted identifier is the interface FQN. Renaming the interface invalidates every
DIRIGIBLE_EXTENSIONSrow pointing at the old name. Documented in the README's "Typed extension points" section.Test plan
mvn -P unit-tests clean install— BUILD SUCCESS in 6:22.JavaEngineIT— 4/4 pass on fresh H2.JavaExtensionDecoratorSampleProjectITend-to-end.🤖 Generated with Claude Code