Skip to content

Typed Java extension points: @ExtensionPoint + @Extension(target = X.class)#6009

Merged
delchev merged 3 commits into
masterfrom
java-typed-extensions
Jun 11, 2026
Merged

Typed Java extension points: @ExtensionPoint + @Extension(target = X.class)#6009
delchev merged 3 commits into
masterfrom
java-typed-extensions

Conversation

@delchev

@delchev delchev commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces the opaque-string @Extension(to = "...") with a typed @Extension(target = X.class) paired with a new @ExtensionPoint marker on the target interface. Consumers retrieve registered implementations through Extensions.find(Class<T>) — they get back instances already cast to the interface type and can invoke methods directly, with no reflection and no Map<String, Object> payloads.

What changes

  • sdk.extensions.Extension — drops String to(); introduces (required) Class<?> target() and an optional String 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 @Extension decorator and the existing Extensions.getExtensions(String) enumeration.
  • NEW sdk.extensions.ExtensionPoint — interface-targeted marker. Metadata + documentation; the contract IS the interface methods.
  • NEW Extensions.find(Class<T>) / Extensions.findFirst(Class<T>) — discovers extension rows by interface FQN, loads each impl from the active ClientClassLoader, validates isAssignableFrom defensively, instantiates via the public no-arg constructor, casts to T, returns.
  • engine-java/ExtensionClassConsumer — uses target.getName() as the persisted extension-point key; rejects (logs + skips) any @Extension class that does not actually implement its declared target, so a downstream Extensions.find(...) can never receive an instance that fails the cast.
  • api-modules-java depends on core-java (for ClientClassLoaderHolder, used to resolve impl classes regardless of where the consumer's interface lives).
  • JavaExtensionDecoratorSampleProjectIT body assertion updated to match the new typed consumer's response shape ("Hello from SampleContribution!").
  • README gets a "Typed extension points" section covering the contract, the FQN-as-identifier caveat, and the cross-runtime note.

Companion change

Sample repo updated in lockstep — already merged so this PR's IT clones the new sample:

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 @Extension decorator string-keyed and exposing Extensions.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_EXTENSIONS row pointing at the old name. Documented in the README's "Typed extension points" section.

Test plan

  • Local: mvn -P unit-tests clean install — BUILD SUCCESS in 6:22.
  • Local: JavaEngineIT — 4/4 pass on fresh H2.
  • CI: integration-tests-h2/postgresql/mssql — exercises the full typed flow via JavaExtensionDecoratorSampleProjectIT end-to-end.

🤖 Generated with Claude Code

delchev and others added 3 commits June 11, 2026 06:58
…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>
@delchev delchev merged commit 44e386d into master Jun 11, 2026
17 of 20 checks passed
@delchev delchev deleted the java-typed-extensions branch June 11, 2026 10:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant