Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .chachalog/js-content-patches.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
# Allowed version bumps: patch, minor, major
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.
423 changes: 423 additions & 0 deletions CONTENT-PATCHES-DEMO.md

Large diffs are not rendered by default.

304 changes: 304 additions & 0 deletions CONTENT-PATCHES-PLAN.md

Large diffs are not rendered by default.

114 changes: 114 additions & 0 deletions docs/2-guides/6-content-patches/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
---
page:
$path: /sites/academy/home/documentation/jahia/8_2/developer/javascript-module-development/content-patches
jcr:title: Writing Content patches
j:templateName: documentation
content:
$subpath: document-area/content
---

Content patches are run-once TypeScript scripts shipped with your module, executed when a new version of the module starts. They reconcile existing content with changes in your content type definitions — the JavaScript equivalent of Jahia's Groovy `META-INF/patches` scripts, with a typed API and built-in guard rails.

Typical uses: removing leftover values of a dropped property, backfilling a default on existing content, converting values after a property type change, deleting a retired node type, or renaming a node type and rebinding its content.

## Declaring a content patch

Call `registerContentPatch` at the top level of a server file (it registers the content patch as a side effect at module startup, like `registerNodeLegacyAction`). Convention: one content patch per file under `src/content-patches/`:

```ts
// src/content-patches/2.0.0-01-remove-legacy-color.server.ts
import { registerContentPatch } from "@jahia/javascript-modules-library";

registerContentPatch(
{
name: "2.0.0-01-remove-legacy-color",
description: "color was dropped from mymodule:banner in 2.0.0 — clean leftover values",
},
({ patch }) => {
patch.removePropertyValues({ nodeType: "mymodule:banner", property: "color" });
},
);
```

The `name` is the content patch's **run-once identity and ordering key**:

- A module's content patches run in lexicographic order of their names. Use the `"<moduleVersion>-<NN>-<slug>"` convention to keep them sorted.
- Execution is recorded under the name in Jahia's module patch status store (`/module-management` → `j:bundlesScripts`, shared with Groovy patches). Whatever the outcome, a recorded content patch **never runs again** — never rename or reorder a released content patch; ship a new one instead.

Content patches run **synchronously** on the module start thread (like actions, they must not be `async`), on the **processing server only**, and by the time they run the module's new definitions are already registered.

## 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).
Comment on lines +40 to +42

```ts
// U1 — remove a property + clean its values
patch.removePropertyValues({ nodeType: "mymodule:banner", property: "color" });

// U2 — add a property + backfill existing content
patch.setPropertyValues({
nodeType: "mymodule:banner",
property: "theme",
onlyIfMissing: true, // default — never clobbers an existing value
value: (node) => (node.getProperty("price").getDouble() > 1000 ? "premium" : "light"),
});

// U3 — change a property's data type + convert values
patch.convertPropertyValues({
nodeType: "mymodule:banner",
property: "priority",
convert: (value) => Number.parseInt(value.getString(), 10), // undefined = leave untouched
});

// U4 — delete a definition (owned by this module)
patch.removeNodeType({
nodeType: "mymodule:legacyBanner",
ifContentExists: "delete", // default is "fail" — destroying content is opt-in
});

// U5 — rename a definition + rebind existing items
patch.changeNodeType({
from: "mymodule:oldBanner",
to: "mymodule:banner", // must exist in the module's current definitions
mapProperties: { legacyTitle: "title" },
});
```

Selection options on the bulk helpers: `scope` (limit to a subtree), `where` (a JCR-SQL2 constraint fragment), `includeSubtypes` (default `true`). Definition operations (`removeNodeType`, `changeNodeType`) only accept node types **owned by your module** — cross-module definition surgery is not supported.

## The imperative escape hatch

For everything else (ACL fixes, node moves, one-off repairs), open a system session or use the batching engine directly:

```ts
registerContentPatch({ name: "2.0.0-02-fix-root-title" }, ({ jcr, log, skip }) => {
jcr.withSystemSession({ workspace: "default" }, (session) => {
const path = "/sites/mysite/contents/catalog-root";
if (!session.nodeExists(path)) skip("nothing to fix on this instance");
session.getNode(path).getRealNode().setProperty("jcr:title", null);
session.save();
log.info(`Cleaned stray jcr:title on ${path}`);
});

jcr.forEachNode(
{ query: "SELECT * FROM [mymodule:banner]", workspaces: ["default", "live"] },
(node) => node.setProperty("migrated", true),
);
});
```

With `locale: null` (the default), system sessions see translation subnodes as plain nodes — usually what content patches want.

## Outcomes and failure semantics

- Returning normally records `.installed`.
- Calling `context.skip(reason)` records `.skipped`.
- Throwing records `.failed`, and the module's **remaining content patches are held back** — persistently, across restarts and redeploys — until the failed record is cleared and the content patch succeeds. The module itself still starts — content patch failures never break startup.

All three outcomes are terminal. Batches already committed before a failure stay committed (JCR has no cross-save transactions), so write content patches to be **idempotent** — the built-in helpers are idempotent by construction.

## Development and testing

- `autoRun` (configuration PID `org.jahia.modules.javascript.modules.engine.contentpatches`, default `true`): set to `false` on development servers to log pending content patches at module start instead of running them.
- The reliable test is an end-to-end one against a real Jahia: provision the previous module version, create content, deploy the new version, and assert the transformed content — see `tests/cypress/e2e/engine/contentPatchTest.cy.ts` in the javascript-modules repository for a complete example (statuses, all five operations, skip/failure semantics).
- To re-run a content patch on a development instance, remove its entry from the `j:bundlesScripts` property of `/module-management` (e.g. in the JCR browser) and restart the module. Dedicated CLI/GraphQL tooling for status, dry runs, and resets is planned.
43 changes: 43 additions & 0 deletions docs/adr/0006-javascript-content-patches.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Run JavaScript content patches through a dedicated registrar backed by core's patch status store

- Status: accepted
- Date: 2026-07-21

## Context and Problem Statement

Java module developers evolve content type definitions with Groovy scripts in `META-INF/patches`, executed by the jahiamodule-extender on bundle events and tracked once-per-(module, path) in the JCR (`/module-management` → `j:bundlesScripts`). JavaScript module developers have no equivalent: no way to remove leftover property values, backfill new properties, convert value types, or rebind content when a node type is renamed — with autocomplete and guard rails instead of Groovy-against-raw-APIs.

Full analysis, verified core facts and the API-first contract live in the working documents `MIGRATIONS-PLAN.md` and `MIGRATIONS-DEMO.md` (repository root).

## Decision Drivers

- Run-once semantics must be cluster-safe and survive engine/context restarts; GraalVM contexts are pooled, so JS top-level code and `bundleInitializer` entries run per context creation and cannot provide them.
- Operations teams already audit Groovy patch execution in one JCR store — a second, JS-only bookkeeping location would fragment that.
- Content patches must see the module's **new** definitions (core registers CND at `RESOLVED`, before `STARTED`).
- Failures must never prevent a module from starting (Groovy parity).
- No Jahia core changes.

## Considered Options

1. **A dedicated `Registrar` (`ContentPatchRegistrar`) executing registry entries of type `content patch` at `BundleEvent.STARTED`, recording results through `BundleInfoJcrHelper` into the same status store as Groovy patches.**
2. Contribute a JS `PatchExecutor` to core's `Patcher` so `.js` files in `META-INF/patches` behave exactly like Groovy patches.
3. An engine-private tracking store (custom JCR nodes) with its own lifecycle hooks.

## Decision Outcome

Chosen option: **1 — dedicated registrar + shared status store.**

- `registerContentPatch(declaration, run)` (library) stores a raw `execute` adapter under registry type `content patch`; `ContentPatchRegistrar` — the once-per-start `Registrar` seam introduced for server extensions — sorts entries by name, filters against recorded statuses, and executes pending ones inside `doWithContext`, handing each a `ContentPatchSupport` Java object (logger, dry-run flag, module metadata, owned-definitions operations).
- Statuses (`.installed` / `.skipped` / `.failed`, all terminal) are recorded under pseudo-paths `/javascript/content-patches/<name>` via the public, OSGi-exported `BundleInfoJcrHelper.get/storeModuleScriptStatus` — one audit trail for Groovy and JS, identical semantics, zero core changes. The store is deliberately NOT written through `Patcher.executeScripts`, which would gate 4-segment-version-prefixed names against the _platform_ version.
- Processing-server-only; a recorded failure is a **persistent barrier** — content patches ordered after it stay held across restarts until the record is cleared — but the module always starts; an `autoRun=false` configuration (PID `…engine.contentpatches`) defers execution on dev/staging servers.
- Content patches are synchronous (like actions — no promises in the GraalVM execution model); the wrapper fails fast on returned thenables.

Option 2 was rejected because `Patcher`'s executor list is a hardcoded whole-list `setPatchers` (last-writer-wins, no unregistration), executors receive script content as a plain string (incompatible with bundled/imported TS), and `.resolved`-time execution would require evaluating JS from a not-yet-started bundle. Option 3 was rejected for fragmenting the ops audit trail that option 1 gets for free.

### Consequences

- Good: run-once, ordering, halt-on-failure and cluster safety come from ~200 lines of registrar code plus an existing core store; neither Contentful nor Sanity offer built-in run-once tracking — this is a differentiator.
- Good: the typed JCR mutation surface added for content patches (java-ts-bind `methodWhitelist`: `setProperty`, `save`, `remove`, `setPrimaryType`, …) also fixes untyped mutation for actions.
- Bad: no checksum on names — an edited-but-same-name content patch silently does not re-run (Groovy parity; tooling may surface hash mismatches later).
- Bad: content patches block the bundle-event thread (Groovy parity); built-in batching bounds the cost, a background-execution option remains future work.
- Follow-up (not in this ADR): CLI/GraphQL tooling (`status`, dry-run-by-default `run`, `reset`, scaffolding) specified in `MIGRATIONS-PLAN.md` §6/§12.
11 changes: 11 additions & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Architecture Decision Records

This directory contains the Architecture Decision Records (ADRs) for the JavaScript Modules project, in [MADR](https://adr.github.io/madr/) style. An ADR captures a single architecturally significant decision: its context, the options considered, and the consequences we accept.

ADRs are numbered in the order they were accepted and are never rewritten once accepted — a superseding decision gets a new ADR that links back.

## Index

| ADR | Title | Status |
|-----|-------|--------|
| [0006](0006-javascript-content-patches.md) | Run JavaScript content patches through a dedicated registrar backed by core's patch status store | accepted |
14 changes: 14 additions & 0 deletions jahia-test-module/settings/definitions.cnd
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,17 @@

[javascriptExample:testVirtualNodeSample] > jnt:content, javascriptExampleMix:javascriptExampleComponent, mix:title
- myProperty (string)

[javascriptExample:patchTestContent] > jnt:content, javascriptExampleMix:javascriptExampleComponent
- legacyColor (string)
- theme (string)
- counter (undefined)

[javascriptExample:patchTestLegacy] > jnt:content, javascriptExampleMix:javascriptExampleComponent
- oldTitle (string)

[javascriptExample:patchTestNew] > jnt:content, javascriptExampleMix:javascriptExampleComponent
- newTitle (string)

[javascriptExample:patchTestDoomed] > jnt:content, javascriptExampleMix:javascriptExampleComponent
- label (string)
154 changes: 154 additions & 0 deletions jahia-test-module/src/react/server/extensions/contentPatches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { registerContentPatch } from "@jahia/javascript-modules-library";

/**
* Test fixtures for JS content patches, exercised by
* tests/cypress/e2e/engine/contentPatchTest.cy.ts.
*
* The content patches run once when this module first starts on a fresh instance. 01 creates the
* fixture content under /sites/systemsite/contents/content-patch-tests, 02-06 exercise the five
* guard-railed operations on it, 07-09 exercise the skip / failure / halt semantics (names order
* execution, so 09 must never run because 08 fails).
*/

const FIXTURES_PARENT = "/sites/systemsite/contents";
const FIXTURES_PATH = `${FIXTURES_PARENT}/content-patch-tests`;

registerContentPatch(
{
name: "1.0.0-01-create-fixture-content",
description:
"Creates the content the following content patches transform (imperative escape hatch)",
},
({ jcr }) => {
jcr.withSystemSession({ workspace: "default" }, (session) => {
const parent = session.getNode(FIXTURES_PARENT);
const folder = parent.hasNode("content-patch-tests")
? parent.getNode("content-patch-tests")
: parent.addNode("content-patch-tests", "jnt:contentList");

const content = folder.addNode("content1", "javascriptExample:patchTestContent");
content.setProperty("legacyColor", "red");
content.setProperty("counter", "42");

const legacy = folder.addNode("legacy1", "javascriptExample:patchTestLegacy");
legacy.setProperty("oldTitle", "hello");

const doomed = folder.addNode("doomed1", "javascriptExample:patchTestDoomed");
doomed.setProperty("label", "to be deleted");

session.save();
});
},
);

registerContentPatch(
{
name: "1.0.0-02-remove-legacy-color",
description: "U1: removes leftover legacyColor values",
},
({ patch }) => {
patch.removePropertyValues({
nodeType: "javascriptExample:patchTestContent",
property: "legacyColor",
});
},
);

registerContentPatch(
{
name: "1.0.0-03-backfill-theme",
description: "U2: backfills the theme property on existing content",
},
({ patch }) => {
patch.setPropertyValues({
nodeType: "javascriptExample:patchTestContent",
property: "theme",
value: "light",
});
},
);

registerContentPatch(
{
name: "1.0.0-04-convert-counter",
description: "U3: converts counter values from string to number",
},
({ patch, log }) => {
patch.convertPropertyValues({
nodeType: "javascriptExample:patchTestContent",
property: "counter",
convert: (value, node) => {
const parsed = Number.parseInt(value.getString(), 10);
if (Number.isNaN(parsed)) {
log.warn(`Unparseable counter on ${node.getPath()}, leaving it untouched`);
return undefined;
}
return parsed;
},
});
},
);

registerContentPatch(
{
name: "1.0.0-05-rename-legacy-type",
description:
"U5: rebinds patchTestLegacy instances to patchTestNew, renaming oldTitle to newTitle",
},
({ patch }) => {
patch.changeNodeType({
from: "javascriptExample:patchTestLegacy",
to: "javascriptExample:patchTestNew",
mapProperties: { oldTitle: "newTitle" },
// the legacy type is still in this module's CND (single-version test fixture), so keep its
// definition registered instead of fighting the redeploy
removeOldDefinition: false,
});
},
);

registerContentPatch(
{
name: "1.0.0-06-remove-doomed-type",
description: "U4: purges patchTestDoomed instances and unregisters the type",
},
({ patch }) => {
patch.removeNodeType({
nodeType: "javascriptExample:patchTestDoomed",
ifContentExists: "delete",
});
},
);

registerContentPatch(
{
name: "1.0.0-07-skip-me",
description: "Records .skipped without halting the following content patches",
},
({ skip }) => {
skip("nothing to do on this instance");
},
);

registerContentPatch(
{
name: "1.0.0-08-fail-on-purpose",
description: "Records .failed and halts the module's remaining content patches",
},
() => {
throw new Error("intentional failure (content patch e2e fixture)");
},
);

registerContentPatch(
{
name: "1.0.0-09-after-failure-never-runs",
description: "Must never run: 08 fails before it",
},
({ jcr }) => {
jcr.withSystemSession({ workspace: "default" }, (session) => {
session.getNode(`${FIXTURES_PATH}/content1`).setProperty("theme", "NEVER");
session.save();
});
},
);
Loading
Loading