Skip to content

feat: content patches — run-once content transformations in JavaScript - #697

Open
romain-pm wants to merge 6 commits into
mainfrom
feature/js-content-patches
Open

feat: content patches — run-once content transformations in JavaScript#697
romain-pm wants to merge 6 commits into
mainfrom
feature/js-content-patches

Conversation

@romain-pm

@romain-pm romain-pm commented Jul 23, 2026

Copy link
Copy Markdown

Part of EPIC #554. Split out of #687 into its own PR for reviewability (the two features are independent — this branches cleanly from main).

What

registerContentPatch lets a JS module declare versioned, run-once content transformations executed at module start, tracked by Jahia's patch status store so each patch runs exactly once per environment (persistent halt-on-failure barrier). Guard-railed operations (patch.*) with low-level jcr.* access as an escape hatch.

registerContentPatch({ name: "1.0.0-01-remove-legacy-color" }, ({ patch }) => {
  patch.removePropertyValues({ nodeType: "myMod:content", property: "legacyColor" });
});

Design & rationale: ADR-0006 (docs/adr/0006-javascript-content-patches.md), CONTENT-PATCHES-PLAN.md.

Notes for reviewers

  • ContentPatchRegistrar implements Registrar (the pre-existing engine SPI) — no dependency on the extension-points mechanism in feat: JS server extension points, client-callable actions, and the JSServerExtensionInvoker SDK #687, hence a standalone PR from main.
  • Live-verified on Jahia 8.2.3, including the getRealNode().setPrimaryType() retype handling and property remapping around retypes (documented in the plan).
  • JcrHelper.doExecuteAsSystem (system-session helper) and the JCR mutation-method entries in .java-ts-bind are introduced here — they back the patch.*/jcr.* operations.

Verification

  • 12 registrar unit tests (ContentPatchRegistrarTest) + full engine-java suite green (29 tests)
  • Library tsc + publint, test-module build (content patches register in the server bundle)
  • Cypress spec contentPatchTest.cy.ts

Tracking

Closes #695.

…JavaScript

Extracted from the JS server-extensions branch into its own PR for reviewability
(part of EPIC #554). registerContentPatch declares versioned, run-once content
transformations executed at module start, tracked by core's patch status store
(persistent halt-on-failure barrier); guard-railed patch.* operations with
low-level jcr.* access. Design: ADR-0006, CONTENT-PATCHES-PLAN.md.

- ContentPatchRegistrar implements Registrar (engine SPI); no dependency on the
  extension-points mechanism, so this branches cleanly from main.
- registerContentPatch + jcr/operations/types library API.
- java-ts-bind: JCR mutation-method whitelist for the guard-railed operations.
- Test-module fixtures (patchTest* CND types) + Cypress spec; registrar unit tests.
@romain-pm
romain-pm requested a review from GauBen as a code owner July 23, 2026 13:08
Copilot AI review requested due to automatic review settings July 23, 2026 13:08
@github-actions

Copy link
Copy Markdown

📝 Documentation Guidelines

Thank you for contributing to our documentation! To ensure your contributions meet our standards, please review these resources:

This comment is posted automatically when changes are detected in the docs/ folder.

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown

🦜 Chachalog

javascript-modules minor
  • New: content patches — versioned, run-once content transformations declared in JavaScript with registerContentPatch, tracked by Jahia's patch status store so each runs exactly once per environment. Guard-railed operations (patch.*) with low-level jcr.* access as an escape hatch.

Create a new entry online or run npx chachalog@0.5.3 prompt to create a new entry locally.

romain-pm added a commit that referenced this pull request Jul 23, 2026
Content patches are an independent feature (ContentPatchRegistrar implements
the engine Registrar SPI, no dependency on the extension-points mechanism).
Moving them to #697 shrinks this PR by ~2300 LOC and separates two review
audiences. This also neutralizes the mislabeled commit 3b6c507: the
content-patch files it swept in are no longer part of this branch's net diff.

Removed here: registerContentPatch + jcr/operations/types, ContentPatchRegistrar
(+support, tests), the content-patch guide/ADR-0006/plan docs, the Cypress spec,
and the patchTest* test-module fixtures.

Left in place intentionally (not content-patch-exclusive, referenced by other
verticals or generally useful): the org.json/test dependencies in the
engine-java pom, the JCR mutation-method entries in .java-ts-bind, and
JcrHelper.doExecuteAsSystem.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces JavaScript/TypeScript “content patches”: versioned, run-once, ordered content transformations registered by JS modules and executed at module startup, with execution tracked in Jahia’s existing module patch status store.

Changes:

  • Added a new library API (registerContentPatch) with typed patch context (patch.* helpers + jcr.* escape hatch) and batching utilities.
  • Added a dedicated engine registrar (ContentPatchRegistrar) that executes pending patches once per module start and records terminal results in /module-managementj:bundlesScripts.
  • Added end-to-end coverage via a test module fixture + Cypress assertions, and documented the feature (ADR + guide + plan/demo + changelog note).

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
tests/cypress/e2e/engine/contentPatchTest.cy.ts Cypress E2E spec asserting status-store results and transformed fixture content.
javascript-modules-library/src/index.ts Exposes registerContentPatch and its public types from the library entrypoint.
javascript-modules-library/src/framework/contentPatches/types.ts Defines the public TS types for patch declarations, context, operations, and JCR helpers.
javascript-modules-library/src/framework/contentPatches/registerContentPatch.ts Implements patch registration into the server registry and sync-only execution adapter.
javascript-modules-library/src/framework/contentPatches/operations.ts Implements guard-railed patch.* helpers (bulk property ops + node type changes/removal).
javascript-modules-library/src/framework/contentPatches/jcr.ts Implements jcr.withSystemSession and jcr.forEachNode with batching + dry-run support.
javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchRegistrarTest.java Unit tests for ordering, once-only behavior, halting on failure, and status persistence.
javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchSupport.java Java support object exposed to JS patches (logger, module metadata, dry-run, nodetype ops).
javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchRegistrar.java New registrar that runs pending patches at bundle start and records outcomes.
javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/JcrHelper.java Adds doExecuteAsSystem helper for system-session JCR mutations from JS.
javascript-modules-engine-java/pom.xml Adds test/support dependencies needed by new code/tests (e.g., JSON, Mockito, GraalJS for tests).
javascript-modules-engine-java/.java-ts-bind/package.json Extends the method whitelist to type JCR mutation APIs needed for content patches.
jahia-test-module/src/react/server/extensions/contentPatches.ts Test-module patch fixtures exercising success/skip/fail/halt semantics and operations.
jahia-test-module/settings/definitions.cnd Adds CND definitions used by the content patch fixtures.
docs/adr/README.md Adds ADR index with content-patches ADR entry.
docs/adr/0006-javascript-content-patches.md ADR documenting the design and operational semantics for JS content patches.
docs/2-guides/6-content-patches/README.md User guide describing how to write and run content patches.
CONTENT-PATCHES-PLAN.md Detailed implementation plan and verified core behavior notes.
CONTENT-PATCHES-DEMO.md Contract/demo document showing intended developer experience and API shape.
.chachalog/js-content-patches.md Release note entry for the new content patches feature.
Comments suppressed due to low confidence (8)

javascript-modules-library/src/framework/contentPatches/operations.ts:108

  • setPropertyValues passes locale.toString() into the user callback, which uses underscores for region locales (e.g. "en_US"). Using toLanguageTag() avoids surprising locale formats and matches typical language-tag inputs.
            const value = resolveValue(node, locale.toString());

javascript-modules-library/src/framework/contentPatches/operations.ts:232

  • In dry-run mode, changeNodeType correctly avoids unregistering, but it still logs "Node type … unregistered", which is misleading. Log a dry-run message when support.isDryRun() is true.
      if (options.removeOldDefinition ?? true) {
        support.unregisterNodeType(options.from);
        log.info(`Node type ${options.from} unregistered`);
      }

javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchRegistrarTest.java:125

  • Typo in test method name: "ContentPatchs" → "ContentPatches".
    public void alreadyRecordedContentPatchsNeverRunAgain() {

javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchRegistrarTest.java:135

  • Typo in test method name: "ContentPatchs" → "ContentPatches".
    public void failedContentPatchsAreRecordedAsTerminal() {

javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchRegistrarTest.java:145

  • Typo in test method name: "ContentPatchs" → "ContentPatches".
    public void failureHaltsTheModulesRemainingContentPatchs() {

javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchRegistrarTest.java:169

  • Typo in test method name: "ContentPatchs" → "ContentPatches".
    public void clearingAFailedRecordReleasesTheHeldContentPatchs() {

javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchRegistrarTest.java:200

  • Typo in test method name: "ContentPatchs" → "ContentPatches".
    public void autoRunDisabledLeavesContentPatchsPending() {

javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchRegistrarTest.java:233

  • Typo in test method name: "ContentPatchs" → "ContentPatches".
    public void modulesWithoutContentPatchsAreANoOp() {

Comment on lines +83 to +87
if (result && typeof (result as PromiseLike<unknown>).then === "function") {
throw new Error(
`Content patch returned a promise: content patches must be synchronous (do not use an async run function)`,
);
}
} catch {
siteLocales = existingLocales(node);
}
return locales ? siteLocales.filter((l) => locales.includes(l.toString())) : siteLocales;
Comment on lines +168 to +170
support.unregisterNodeType(options.nodeType);
log.info(`Node type ${options.nodeType} unregistered`);
return report;
Comment on lines +79 to +99
NodeTypeRegistry registry = NodeTypeRegistry.getInstance();
if (!registry.hasNodeType(name)) {
getLogger(bundle.getSymbolicName()).info(
"Node type {} is not registered on this instance, nothing to unregister", name);
return;
}
ExtendedNodeType type;
try {
type = registry.getNodeType(name);
} catch (NoSuchNodeTypeException e) {
return; // raced away, nothing to do
}
if (!bundle.getSymbolicName().equals(type.getSystemId())) {
throw new IllegalArgumentException("Node type " + name + " is owned by '" + type.getSystemId()
+ "', not by this module ('" + bundle.getSymbolicName()
+ "') — content patches may only remove their own definitions");
}
if (dryRun) {
getLogger(bundle.getSymbolicName()).info("[dry-run] would unregister node type {}", name);
return;
}
Comment on lines +40 to +42
## The `migrate.*` helpers

Every helper iterates both the `default` and `live` workspaces (override with `workspaces`), commits in batches (`batchSize`, default 100), handles internationalized properties on their translation subnodes, logs progress, and no-ops gracefully when the node type was never registered on this instance (fresh installs).
javascript-modules: minor
---

New: content patches — versioned, run-once content transformations declared in JavaScript with `registerContentPatch`, tracked by Jahia's patch status store so each runs exactly once per environment. Guard-railed operations (`patch.*`) with low-level `jcr.*` access as an escape hatch.
}

@Test
public void runsPendingContentPatchsInNameOrderAndRecordsResults() {
cy.apollo logs query.loc.source.body, so it requires a parsed gql document;
passing raw strings made all 5 contentPatchTest specs fail with
"Cannot read properties of undefined (reading 'source')". Use queryFile
fixtures like the other specs.

@GauBen GauBen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting design, cool ideas

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

…allback

skip() throws ContentPatchSkipped, but when called inside jcr.withSystemSession
or jcr.forEachNode the throw crosses a host boundary: doExecuteAsSystem wrapped
every exception in a new IllegalStateException, destroying the guest exception's
identity, so the adapter recorded .failed (a persistent barrier) instead of
.skipped — exactly the guide's documented 'nothing to fix on this instance'
pattern. Rethrow unchecked exceptions unchanged so GraalVM restores the guest
identity, make skip detection flag-based so no wrapping can break it again, and
point the 07-skip-me e2e fixture at the nested pattern as a regression test.
…fter

removeNodeType and changeNodeType ran their batched (and committed) content
mutations first and only hit the ownership check inside unregisterNodeType
afterwards — so a patch targeting another module's type destroyed or retyped
its content before being refused, and with removeOldDefinition: false the
check never ran at all. Extract the systemId check into
ContentPatchSupport.assertOwnedNodeType, call it on 'from' and 'to' before any
forEachNode, and keep the unregisterNodeType check as defense in depth.

Also stop setPropertyValues from persisting empty translation subnodes when
the value callback returns undefined: resolve the value before getOrCreateI18N.
…failures

isDryRun() was exposed and documented across the API but nothing could ever
set it: wire a dryRun property on the existing configuration PID — patches
then execute without persisting and without recording, staying pending. And
when storing a result fails after a patch executed, log explicitly that the
run-once guarantee is broken (the patch will re-run) and halt the module's
remaining patches instead of dying silently in the outer catch.
- ADR referenced MIGRATIONS-*.md working docs (renamed CONTENT-PATCHES-*.md)
  and the registry type 'content patch' (actual: 'content-patch')
- the guide compared registerContentPatch to registerNodeLegacyAction, which
  only exists on the server-extensions branch; document the new dryRun config
- drop the unused org.graalvm.js:js test dependency (no test uses it here)
- warn that unpadded numbers break lexicographic patch ordering at 10+
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.

Content patches declared in JavaScript

3 participants