Skip to content

Content patches: Java-first framework with Groovy and TypeScript bridges #725

Description

@romain-pm

Sub-issue of #695, following up on the tech-lead feedback in the #697 review (pullrequestreview-4916430758):

My biggest take away is that we should make this API in Java and expose it to both JS and Groovy scripts, because we only widen the gap with a JS-first implementation

While I agree there is a need for this it still doesn't make it easier on the CND-side to understand the migration process

This issue captures the analysis and re-layering plan: a small framework in Java (hosted in the javascript-modules engine for now, candidate for Jahia core in the long run), with bridges + tooling + autocomplete + samples for both Groovy and TypeScript. Implementation is deliberately out of scope here and will be discussed once the layering is agreed.

Analysis — where the logic lives today (#697)

The branch already splits along the right line for semantics, but not for operations:

Layer Today Notes
Lifecycle & run-once semantics: STARTED hook, ordering, pending-set vs status store, halt-on-failure barrier, autoRun/dryRun config JavaContentPatchRegistrar Records to the same store as Groovy patches (/module-managementj:bundlesScripts) via core's BundleInfoJcrHelper
Definition guard rails (owned-type assertion, unregisterNodeType, registered-type checks), per-patch logger, module metadata, dry-run flag JavaContentPatchSupport
System-session helper JavaJcrHelper.doExecuteAsSystem
Batch engine: jcr.forEachNode — identifier snapshotting, per-batch sessions with save/refresh cycles, dry-run discard, per-workspace reports, query building from nodeType/scope/where TypeScriptcontentPatches/jcr.ts (~145 lines) Language-agnostic domain logic
The five guard-railed operations: patch.removePropertyValues / setPropertyValues / convertPropertyValues / changeNodeType / removeNodeType, incl. i18n handling (translation subnodes, locale targeting) and the getRealNode().setPrimaryType() retype dance with property remapping around the retype TypeScriptcontentPatches/operations.ts (~260 lines) Language-agnostic domain logic — the hard-won part
Registration UX: registerContentPatch, name validation, duplicate detection, skip()/sync contract TypeScript JS-modules-specific by nature
Tooling (GraphQL admin, CLI status/run/reset/create) Not built — specified in CONTENT-PATCHES-PLAN.md §6

So the semantics are already in Java; what is JS-only is precisely the domain knowledge — the batching, i18n and retype know-how that a Groovy or Java developer must otherwise re-hand-roll (ScrollableQuery + getRealNode() + save/refresh boilerplate, as the reference Groovy scripts in visibility, templates-system, site-settings-seo and jcontent all do today, each slightly differently). Keeping it in TS is what "widens the gap".

For contrast, a Groovy module patch today gets exactly two bindings (log, setResult — core's GroovyPatcher) and raw APIs: no batching helper, no dry-run anywhere, no i18n helpers, no reports, and re-running one means hand-editing the j:bundlesScripts JSON.

Target layering

  1. Java framework (in javascript-modules-engine for now; candidate for core later) — everything language-agnostic: the batch engine, the five operations, i18n handling, operation reports, definition guard rails, dry-run — joining the lifecycle/status pieces already there. Callbacks (value, convert, per-node visitors) become plain functional interfaces: Groovy closures and JS functions both coerce to those natively, so one API serves both languages without per-language logic.
  2. Groovy bridge — Groovy patches keep their existing META-INF/patches lifecycle (extender + shared status store: nothing changes there); they gain the operations API, runnable samples, and an IDE autocomplete story (provided-scope dependency for editing; runtime access via OSGi service lookup, which is dynamic-dispatch-safe in Groovy).
  3. TypeScript bridgeregisterContentPatch keeps its exact published surface; patch.* / jcr.* become thin typed wrappers delegating to the Java operations, with the idiomatic TS façade preserved (object literals, unions, callbacks) over the java-ts-bind-typed calls.

What stays language-specific by design: the declaration/registration UX, the typed façades, samples, docs, and (JS side) the vite/CLI integration.

DevEx impact — the key assessment

JS module developers: no visible regression. The API surface of #697 (registerContentPatch, patch.*, jcr.*, reports, dry-run, skip semantics) is preserved by the TS façade, and the run-once/ordering/halt semantics don't move (already Java). Two things to hold the line on: the façade must stay idiomatic (hand-authored types over the generated binding, not raw java-ts-bind output), and error messages must cross the polyglot boundary as first-class messages. Side benefit: for constant-value operations, moving the loop into Java removes per-node JS↔Java crossings (today the TS loop crosses the boundary for every hasProperty/getProperty/setProperty call); only per-node callbacks still cross.

Java/Groovy module developers: the big win. They gain the guard-railed operations — batching, i18n, dry-run, fresh-install no-ops, reports — on the audit store they already use, replacing hand-rolled boilerplate that today varies script by script. This has immediate internal customers: the reference Groovy scripts inventoried in CONTENT-PATCHES-PLAN.md §2.2 each re-implement a subset of this, without dry-run and with known i18n traps.

One interim awkwardness to own: consuming an API exported by the JS engine bundle is a surprising dependency for a pure-Java module. Mitigation: isolate the API in a dedicated exported package with no engine/Graal types in its signatures (core-migration-ready by construction) and position it explicitly as engine-hosted-for-now. This is also the honest argument for the core move: the API's natural home is next to Patcher/BundleInfoJcrHelper, not in a rendering engine.

Ops/admin teams: one engine, one story. Same batching, logging and report format whatever the language; the planned tooling (GraphQL admin + CLI status/run/reset, dry-run by default) is built once on the Java framework and serves both ecosystems — including reset for Groovy patch records, replacing today's hand-editing of j:bundlesScripts.

The CND-side comprehension gap (second review point) becomes addressable language-neutrally instead of JS-only: a validate sweep (content-vs-definitions divergence report) in the Java framework serves Java and JS modules alike; a docs matrix mapping CND change → required operation (property dropped → removePropertyValues, type renamed → changeNodeType, …); later, CND-diff-aware scaffolding that suggests the patch skeleton. The inversion doesn't solve migration comprehension by itself — it is kept as an explicit workstream (P5) rather than assumed away.

Costs & risks

  • Rework of ~400 lines of proven TS into Java. Bounded: the registrar unit tests and the two-version Cypress e2e assert the public surface, which does not change — they are the safety net for a behavior-preserving move.
  • Polyglot interop seams: JS undefined vs Java null for "no value / leave untouched" sentinels, JS number → JCR long/double, exception unwrapping across the boundary (the flag-based skip() detection in feat: content patches — run-once content transformations in JavaScript #697 is the precedent that host-boundary exception identity can't be trusted). Each is known and testable.
  • API-stability pressure arrives earlier: an API consumed by Groovy scripts and Java modules is harder to iterate than a TS-internal one, and the core move later puts it under core's compatibility rules. The engine-hosted phase is exactly the time to shake the API out.

Open questions

  1. Packaging of the Java API: exported package of the engine bundle (as feat: JS server extension points, client-callable actions, and the JSServerExtensionInvoker SDK #687 does for the SDK) vs a separate small content-patches-api artifact; and a package name that survives the core move (rename + deprecated delegating façade, or pick-once).
  2. Options shape in Java: Map-friendly signatures (Groovy named args and JS object literals both map naturally) vs typed builders — pick what keeps both bridges idiomatic, not what is prettiest in Java.
  3. Groovy static-import ergonomics: dynamic OSGi lookup is the guaranteed baseline; whether the patch-script classloader can also resolve the exported API statically needs a short spike (moot once core-hosted).
  4. Scope of the Groovy bridge: operations-only (recommended — the extender lifecycle already provides run-once for Groovy patches), or eventually a declaration API for Java modules too? That is really the core-framework question, deferred to P6.
  5. Sequencing vs feat: content patches — run-once content transformations in JavaScript #697: merge as-is and invert the internals in a follow-up (the surface is compatible), or rework on the branch so the Java API, TS bridge and Groovy samples land together before the feature's first release.

Plan (phases — implementation discussion comes later)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions