From 02c1cf6641c548e1060ee19582ac9283a102e6ea Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Thu, 23 Jul 2026 15:02:27 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20content=20patches=20=E2=80=94=20run?= =?UTF-8?q?-once=20content=20transformations=20declared=20in=20JavaScript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .chachalog/js-content-patches.md | 6 + CONTENT-PATCHES-DEMO.md | 423 ++++++++++++++++++ CONTENT-PATCHES-PLAN.md | 304 +++++++++++++ docs/2-guides/6-content-patches/README.md | 114 +++++ docs/adr/0006-javascript-content-patches.md | 43 ++ docs/adr/README.md | 11 + jahia-test-module/settings/definitions.cnd | 14 + .../react/server/extensions/contentPatches.ts | 154 +++++++ .../.java-ts-bind/package.json | 65 ++- javascript-modules-engine-java/pom.xml | 21 + .../modules/engine/js/server/JcrHelper.java | 28 ++ .../contentpatches/ContentPatchRegistrar.java | 222 +++++++++ .../contentpatches/ContentPatchSupport.java | 106 +++++ .../ContentPatchRegistrarTest.java | 239 ++++++++++ .../src/framework/contentPatches/jcr.ts | 145 ++++++ .../framework/contentPatches/operations.ts | 244 ++++++++++ .../contentPatches/registerContentPatch.ts | 99 ++++ .../src/framework/contentPatches/types.ts | 205 +++++++++ javascript-modules-library/src/index.ts | 12 + .../cypress/e2e/engine/contentPatchTest.cy.ts | 128 ++++++ 20 files changed, 2581 insertions(+), 2 deletions(-) create mode 100644 .chachalog/js-content-patches.md create mode 100644 CONTENT-PATCHES-DEMO.md create mode 100644 CONTENT-PATCHES-PLAN.md create mode 100644 docs/2-guides/6-content-patches/README.md create mode 100644 docs/adr/0006-javascript-content-patches.md create mode 100644 docs/adr/README.md create mode 100644 jahia-test-module/src/react/server/extensions/contentPatches.ts create mode 100644 javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchRegistrar.java create mode 100644 javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchSupport.java create mode 100644 javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchRegistrarTest.java create mode 100644 javascript-modules-library/src/framework/contentPatches/jcr.ts create mode 100644 javascript-modules-library/src/framework/contentPatches/operations.ts create mode 100644 javascript-modules-library/src/framework/contentPatches/registerContentPatch.ts create mode 100644 javascript-modules-library/src/framework/contentPatches/types.ts create mode 100644 tests/cypress/e2e/engine/contentPatchTest.cy.ts diff --git a/.chachalog/js-content-patches.md b/.chachalog/js-content-patches.md new file mode 100644 index 00000000..7f558718 --- /dev/null +++ b/.chachalog/js-content-patches.md @@ -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. diff --git a/CONTENT-PATCHES-DEMO.md b/CONTENT-PATCHES-DEMO.md new file mode 100644 index 00000000..894fd927 --- /dev/null +++ b/CONTENT-PATCHES-DEMO.md @@ -0,0 +1,423 @@ +# Content patch scripts — developer experience demo + +Companion to [CONTENT-PATCHES-PLAN.md](CONTENT-PATCHES-PLAN.md). Everything below is **pre-implementation**: this is the exact developer-facing contract the implementation must fulfill (API-first spec). Decisions locked 2026-07-21: Groovy-parity fresh-install & failure semantics, `default`+`live` by default, definition operations limited to the module's **own** definitions, implemented directly on `feature/js-server-extensions`. + +## 0. Scenario + +Module `acme-catalog` evolves from **1.0.0** to **2.0.0**. The CND diff drives all five use cases: + +```cnd +// settings/definitions.cnd — v1.0.0 + + +[acme:product] > jnt:content, jmix:structuredContent + - title (string) i18n mandatory + - color (string) // U1: dropped in 2.0.0 + - priority (string) // U3: string → long in 2.0.0 + - price (double) + +[acme:legacyBadge] > jnt:content, jmix:structuredContent // U4: deleted in 2.0.0 + - label (string) + +[acme:oldTeaser] > jnt:content, jmix:structuredContent // U5: renamed in 2.0.0 + - teaserTitle (string) i18n + - body (string, richtext) i18n +``` + +```cnd +// settings/definitions.cnd — v2.0.0 + + +[acme:product] > jnt:content, jmix:structuredContent + - title (string) i18n mandatory + - priority (long) // U3: was string + - price (double) + - theme (string) = 'light' autocreated // U2: new — existing content needs backfill + +[acme:teaser] > jnt:content, jmix:structuredContent // U5: replaces acme:oldTeaser + - title (string) i18n // was teaserTitle + - body (string, richtext) i18n +``` + +Module layout in 2.0.0 — one content patch per file, name = run-once identity: + +``` +acme-catalog/ +├── package.json # "version": "2.0.0" +├── settings/definitions.cnd # v2 CND above +└── src/ + ├── components/… + └── content-patches/ + ├── 2.0.0-01-remove-legacy-color.server.ts + ├── 2.0.0-02-backfill-theme.server.ts + ├── 2.0.0-03-convert-priority.server.ts + ├── 2.0.0-04-drop-legacy-badge.server.ts + ├── 2.0.0-05-rename-old-teaser.server.ts + └── 2.0.0-06-clean-catalog-root-title.server.ts +``` + +> Build note: files ride the existing server bundle (side-effect registration, like `registerAction`). Prerequisite: extend the vite-plugin default server glob from `**/*.server.{jsx,tsx}` to `**/*.server.{js,jsx,ts,tsx}` — today plain `.ts` server files require a manual `inputGlob` override (the jahia-test-module already hits this for its extension fixtures). + +## 1. U1 — remove a property + clean its values + +```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 acme:product in 2.0.0 — clean leftover values", + }, + ({ patch }) => { + patch.removePropertyValues({ nodeType: "acme:product", property: "color" }); + }, +); +``` + +What the helper does for you: iterates `default` **and** `live`, batches (100 nodes per `save()`/`refresh(false)` cycle), handles i18n properties on their translation subnodes, no-ops gracefully if the type was never registered on this instance (fresh install), logs progress per batch, honors dry-run mode. + +## 2. U2 — add a property + backfill existing content + +```ts +// src/content-patches/2.0.0-02-backfill-theme.server.ts +import { registerContentPatch } from "@jahia/javascript-modules-library"; + +registerContentPatch( + { + name: "2.0.0-02-backfill-theme", + description: "theme is autocreated for new products; backfill existing ones", + }, + ({ patch }) => { + patch.setPropertyValues({ + nodeType: "acme:product", + property: "theme", + onlyIfMissing: true, // default — never clobbers an existing value + value: (node) => + node.hasProperty("price") && node.getProperty("price").getDouble() > 1000 + ? "premium" + : "light", + }); + }, +); +``` + +`value` is either a constant or a per-node function; `node` is a fully typed `JCRNodeWrapper`, so `getProperty(…).getDouble()` autocompletes. Returning `undefined` skips the node (counted and logged). + +## 3. U3 — change a property's data type + convert values + +```ts +// src/content-patches/2.0.0-03-convert-priority.server.ts +import { registerContentPatch } from "@jahia/javascript-modules-library"; + +registerContentPatch( + { + name: "2.0.0-03-convert-priority", + description: "priority changed string → long in 2.0.0; convert stored values", + }, + ({ patch, log }) => { + patch.convertPropertyValues({ + nodeType: "acme:product", + property: "priority", + convert: (value, node) => { + const parsed = Number.parseInt(value.getString(), 10); + if (Number.isNaN(parsed)) { + log.warn( + `Unparseable priority "${value.getString()}" on ${node.getPath()} — defaulting to 0`, + ); + return 0; + } + return parsed; + }, + }); + }, +); +``` + +`convert` receives the old value as a typed `JCRValueWrapper` (the property may still carry the _old_ type) and must return the new value; the helper rewrites the property under the new definition. Returning `undefined` leaves that node untouched. + +## 4. U4 — delete a definition programmatically + +```ts +// src/content-patches/2.0.0-04-drop-legacy-badge.server.ts +import { registerContentPatch } from "@jahia/javascript-modules-library"; + +registerContentPatch( + { + name: "2.0.0-04-drop-legacy-badge", + description: "acme:legacyBadge is retired; purge instances and undeploy the definition", + }, + ({ patch }) => { + patch.removeNodeType({ + nodeType: "acme:legacyBadge", + ifContentExists: "delete", // default is "fail" — destroying content is opt-in + }); + }, +); +``` + +Guard rails: the type must belong to **this module** (own-definitions only — cross-module surgery like the visibility/jcontent scripts is out of scope); with the default `"fail"`, the content patch is marked `.failed` and content is left untouched if instances still exist. + +## 5. U5 — rename a definition + rebind existing items + +```ts +// src/content-patches/2.0.0-05-rename-old-teaser.server.ts +import { registerContentPatch } from "@jahia/javascript-modules-library"; + +registerContentPatch( + { + name: "2.0.0-05-rename-old-teaser", + description: "acme:oldTeaser → acme:teaser; teaserTitle → title", + }, + ({ patch }) => { + patch.changeNodeType({ + from: "acme:oldTeaser", + to: "acme:teaser", // must exist in the 2.0.0 CND (registered before content patches run) + mapProperties: { teaserTitle: "title" }, // i18n values follow their translation nodes + // removeOldDefinition: true (default) — undeploys acme:oldTeaser afterwards + }); + }, +); +``` + +Per node: `setPrimaryType(to)`, property renames (i18n-aware), then the old definition is undeployed once all instances are rebound in both workspaces. + +## 6. Escape hatch — imperative content patch (templates-system-style repair) + +```ts +// src/content-patches/2.0.0-06-clean-catalog-root-title.server.ts +import { registerContentPatch } from "@jahia/javascript-modules-library"; + +registerContentPatch( + { + name: "2.0.0-06-clean-catalog-root-title", + description: "an old import set a stray non-i18n jcr:title on the catalog root", + }, + ({ jcr, log, skip }) => { + jcr.withSystemSession({ workspace: "default" }, (session) => { + const path = "/sites/acme/contents/catalog-root"; + if (!session.nodeExists(path)) skip("catalog root not present on this instance"); + const node = session.getNode(path); + node.getRealNode().setProperty("jcr:title", null); // bypass i18n resolution, remove raw value + session.save(); + log.info(`Cleaned stray non-i18n jcr:title on ${path}`); + }); + }, +); +``` + +For bulk imperative work, `jcr.forEachNode({ query: "SELECT * FROM [acme:product]", workspaces: ["default", "live"] }, (node) => { … })` provides the same batching/save/progress engine the `patch.*` helpers use. + +## 7. The API contract (what autocomplete shows) + +Trimmed `.d.ts` — the implementation target. **Content patches are synchronous** (no promises), consistent with actions and the GraalJS execution model; a returned thenable fails fast with a clear error. All underlying JCR calls block anyway, so `async` would buy nothing. + +```ts +/** + * Registers a content patch: a run-once script executed on the processing server when a new version + * of this module starts, tracked in Jahia's module patch status store. + */ +export function registerContentPatch( + declaration: ContentPatchDeclaration, + run: (context: ContentPatchContext) => void, +): void; + +interface ContentPatchDeclaration { + /** + * Run-once identity AND ordering key (lexicographic within the module). Convention: + * "--", e.g. "2.0.0-01-remove-legacy-color". NEVER rename or reorder + * after release — ship a new content patch instead. Duplicate names in one module throw at + * registration time. + */ + name: string; + /** Shown in logs, CLI and GraphQL status. */ + description?: string; +} + +interface ContentPatchContext { + /** High-level, guard-railed operations. */ + migrate: ContentPatchOperations; + /** Lower-level JCR access: system sessions + the shared batching iterator. */ + jcr: ContentPatchJcr; + /** SLF4J-backed logger (org.jahia.modules.javascript.modules.engine.contentpatches.). */ + log: Logger; + /** + * True in dry-run mode (the DEFAULT for `jahia content-patches run`) — helpers report instead of + * saving. + */ + dryRun: boolean; + /** Abort now and record `.skipped` (terminal, Groovy parity). */ + skip(reason: string): never; + module: { name: string; version: string }; +} + +/** Common selection options for bulk operations. */ +interface NodeSelection { + nodeType: string; + /** @default true */ + includeSubtypes?: boolean; + /** Limit to a subtree, e.g. "/sites/acme". @default whole workspace */ + scope?: string; + /** Optional SQL-2 constraint appended to the generated query, e.g. "price > 1000". */ + where?: string; + /** @default ["default", "live"] */ + workspaces?: ("default" | "live")[]; + /** Nodes per save/refresh cycle. @default 100 */ + batchSize?: number; +} + +type PropertyValue = string | number | boolean | Date | JCRNodeWrapper; + +interface ContentPatchOperations { + /** U1 — remove leftover values of a property (i18n-aware: cleans translation nodes too). */ + removePropertyValues(options: NodeSelection & { property: string }): OpReport; + + /** U2 — set a value on existing content. */ + setPropertyValues( + options: NodeSelection & { + property: string; + /** + * Constant, or per-node function. Return undefined to skip a node. For i18n properties the + * function is called once per existing translation (locale passed). + */ + value: PropertyValue | ((node: JCRNodeWrapper, locale?: string) => PropertyValue | undefined); + /** @default true — never overwrite an existing value unless set to false. */ + onlyIfMissing?: boolean; + }, + ): OpReport; + + /** U3 — rewrite values after a property type change. Return undefined to leave a node untouched. */ + convertPropertyValues( + options: NodeSelection & { + property: string; + convert: (value: JCRValueWrapper, node: JCRNodeWrapper) => PropertyValue | undefined; + }, + ): OpReport; + + /** + * U4 — undeploy a node type OWNED BY THIS MODULE. + * + * @default ifContentExists: "fail" — refuses while instances remain; "delete" purges them first. + */ + removeNodeType(options: { nodeType: string; ifContentExists?: "fail" | "delete" }): OpReport; + + /** + * U5 — rebind all instances of `from` to `to` (setPrimaryType + optional property renames), then + * undeploy `from` (own definition only). + */ + changeNodeType( + options: Omit & { + from: string; + to: string; + mapProperties?: Record; + /** @default true */ + removeOldDefinition?: boolean; + }, + ): OpReport; +} + +interface ContentPatchJcr { + /** + * Typed system session (new engine helper alongside doExecuteAsGuest). @default workspace + * "default" + */ + withSystemSession( + options: { workspace?: "default" | "live"; locale?: string | null }, + callback: (session: JCRSessionWrapper) => T, + ): T; + /** The batching engine behind migrate.*, for imperative bulk work. */ + forEachNode( + options: + | NodeSelection + | (Omit & { query: string }), + callback: (node: JCRNodeWrapper) => void, + ): OpReport; +} + +interface OpReport { + matched: number; + updated: number; + skipped: number; + byWorkspace: Record; +} +``` + +(`JCRNodeWrapper` / `JCRSessionWrapper` / `JCRValueWrapper` are the existing generated types — gaining `setProperty`, `addNode`, `remove`, `save`, `move`, `setPrimaryType`, `getRealNode`, … via the methodWhitelist extension, plan §5.) + +## 8. Dev loop (what "test easily" looks like day-to-day) + +Dev/staging servers set `autoRun: false` in the engine config (new setting); production keeps the default `true` (content patches run automatically at module start, exactly like Groovy patches). The CLI is safe by default: **`run` is a dry-run unless you pass `--no-dry-run`** (Sanity-inspired), and applying asks for confirmation (`--yes` for CI). + +```console +$ npx jahia content-patches create backfill-theme # scaffolding: computes the next NN +Created src/content-patches/2.0.0-02-backfill-theme.server.ts + +$ yarn watch # build + deploy 2.0.0 as usual +# engine log: [content-patches] acme-catalog 2.0.0: 6 pending content patches (autoRun disabled — use CLI) + +$ npx jahia content-patches status +MODULE NAME STATUS +acme-catalog 2.0.0-01-remove-legacy-color pending +acme-catalog 2.0.0-02-backfill-theme pending +acme-catalog 2.0.0-03-convert-priority pending +acme-catalog 2.0.0-04-drop-legacy-badge pending +acme-catalog 2.0.0-05-rename-old-teaser pending +acme-catalog 2.0.0-06-clean-catalog-root-title pending + +$ npx jahia content-patches run # dry-run by default — nothing is saved +2.0.0-01-remove-legacy-color default: 128 matched, 96 would update · live: 117/88 +2.0.0-02-backfill-theme default: 128/128 · live: 117/117 +2.0.0-03-convert-priority default: 128/126 (2 skipped, see warnings) · live: 117/115 +2.0.0-04-drop-legacy-badge default: 12 instances would be deleted · live: 9 +2.0.0-05-rename-old-teaser default: 34 rebound · live: 31 · acme:oldTeaser would be undeployed +2.0.0-06-clean-catalog-root-title 1 node would be fixed +dry-run: 6 content patches, 0 errors — pass --no-dry-run to apply + +$ npx jahia content-patches run --no-dry-run +Apply 6 content patches to acme-catalog on http://localhost:8080? [y/N] y +… +2.0.0-06-clean-catalog-root-title .installed (0.1s) +6 installed, 0 failed, 0 skipped + +$ npx jahia content-patches run --no-dry-run --yes # rerun-safety: identity is the name +nothing pending + +# iterate on one script against disposable content: +$ npx jahia content-patches reset --name 2.0.0-03-convert-priority +$ npx jahia content-patches run --name 2.0.0-03-convert-priority # dry-run again +``` + +Failure in production (autoRun on): Groovy parity — the module still starts, the failing content patch is recorded `.failed`, remaining content patches for that module are held back and stay `pending`: + +``` +ERROR [content-patches] acme-catalog 2.0.0-03-convert-priority failed: — recorded .failed; + holding 3 remaining content patches for acme-catalog +``` + +Recovery beats Groovy (no hand-editing `j:bundlesScripts`): ship the fixed script (same name), then `npx jahia content-patches reset --name … && npx jahia content-patches run` (or the GraphQL mutations, admin-gated). + +## 9. Testing your content patches (Q5 — the two levels explained) + +**Level 1 — two-version e2e against a real Jahia (the backbone, in plan phases 1-3).** +The only test that proves JCR reality: i18n translation nodes, definition registration timing, live-workspace behavior. The recipe (shipped as a copyable harness + docs, and used by our own Cypress specs): + +1. `yarn pack` the module twice — v1.0.0 (old CND, no content patches) and v2.0.0. +2. Start Jahia (docker), provision v1, create fixture content (GraphQL). +3. Deploy v2, wait for content patch status via GraphQL. +4. Assert content transformed + statuses `.installed` + redeploy is a no-op. + +Cost: minutes per run, needs docker. This is what "test easily" means for correctness — today's Groovy equivalent has _no_ recipe at all. + +**Level 2 — vitest recorder fake (optional extra, the open part of Q5).** +A pure-JS `ContentPatchContext` stand-in: `patch.*`/`jcr.*` record their calls instead of touching a repository; your `convert`/`value` callbacks run for real. Tests assert structure and callback logic in milliseconds, no docker: + +```ts +const { context, calls } = createContentPatchTestContext(); +runContentPatch("2.0.0-03-convert-priority", context); +expect(calls.convertPropertyValues[0].options.nodeType).toBe("acme:product"); +expect(calls.convertPropertyValues[0].options.convert(fakeValue("42"), fakeNode())).toBe(42); +``` + +What it can NOT catch: anything about actual JCR behavior. It's a fast guard for CI on the module side, not a substitute for level 1. (A third option — an in-memory JCR emulation — is deliberately rejected: high build/maintenance cost, false confidence on i18n/publication semantics.) + +**Decision (2026-07-21)**: level 1 only in v1 — module devs run Cypress + docker in CI (Jahia standard). The level-2 recorder is deferred to plan phase 4. diff --git a/CONTENT-PATCHES-PLAN.md b/CONTENT-PATCHES-PLAN.md new file mode 100644 index 00000000..90b68bb5 --- /dev/null +++ b/CONTENT-PATCHES-PLAN.md @@ -0,0 +1,304 @@ +# TypeScript content patch scripts for JavaScript modules — implementation plan + +- Status: **accepted** 2026-07-21 (all §10 questions resolved) +- Date: 2026-07-21 +- Baseline: branch `feature/js-server-extensions` (implementation lands directly on it); core facts verified against `Jahia/jahia@main` (8.2.3.0 prep, sha `5e20152`) +- Developer-facing contract (API-first spec, all five use cases as real files): [CONTENT-PATCHES-DEMO.md](CONTENT-PATCHES-DEMO.md) + +## 1. Goal + +Give JavaScript module developers the equivalent of Groovy `META-INF/patches` scripts — versioned, run-once data/definition content patches shipped with the module — but written in TypeScript, with autocomplete, guard rails, and a testable dev loop. + +Target use cases (all must be expressible): + +| # | Use case | Typical trigger | +| --- | ------------------------------------------------------------- | ------------------------- | +| U1 | Remove a property + clean its values on all existing content | property dropped from CND | +| U2 | Add a property + backfill a default value on existing content | new required property | +| U3 | Change a property's data type + convert existing values | e.g. string → long | +| U4 | Delete a node type definition programmatically | retired component | +| U5 | Rename a node type + rebind existing content to the new one | component renamed | + +Plus an imperative escape hatch for everything else (ACL fixes, node moves, cross-workspace repairs — the kinds of things the reference Groovy scripts do). + +## 2. Verified ground truth + +### 2.1 How Groovy module patches actually work (Jahia core) + +Verified in `Jahia/jahia` sources; key files: `core/.../org/jahia/tools/patches/Patcher.java`, `bundles/jahiamodule-extender/.../Activator.java`, `core/.../modulemanager/persistence/jcr/BundleInfoJcrHelper.java`. + +- **Discovery**: `Activator.handlePatches` runs on OSGi bundle events, **processing server only** (`Activator.java:434-436`). It scans `bundle.findEntries("META-INF/patches", "*..*", true)` where event ∈ `resolved | started | stopped` (`Activator.java:147-153, 1289`). `.resolved` runs before module content import; there is no `.uninstalled`/`.updated` for modules. +- **Run-once tracking**: scripts inside a jar can't be renamed, so status is stored in JCR at `/module-management` (`jnt:moduleManagement`), property `j:bundlesScripts` = JSON `{ "": { "": "" } }` (`BundleInfoJcrHelper.java:158-196`). Keyed by **symbolic name + entry path only** — no checksum, no module version. Result strings: `.installed`, `.failed`, `.skipped` (all terminal, never retried — retry requires hand-editing the JCR JSON), `keep` (re-run on every matching event). +- **Ordering**: plain string sort of the _filename_ (`Patcher.java:103`) — hence prefixes like `8.2.0-01-`. +- **Version-gating trap**: filenames starting with a **4-segment** version (`8.2.0.0-…`) are gated against the _platform_ version and auto-skipped on fresh installs (`Patcher.java:102, 236-239`). Module scripts use 3-segment prefixes precisely to avoid this. +- **Failure semantics**: exceptions are caught, logged, recorded as `.failed`; **the module starts anyway**; no retry, no rollback. +- **Fresh installs**: module patches have no baseline notion — they run at the module's first `started`/`resolved` too. Scripts self-guard (`nodeExists`, etc.). +- **Script contract**: Groovy gets exactly two bindings: `log` and `setResult` (`GroovyPatcher.java:74-88`). No session, no user injected. +- **Extensibility**: `Patcher`'s executor list is hardcoded; the only mutator is whole-list `setPatchers` (last-writer-wins, fragile). **But** `Patcher.executeScripts(Collection, phase, BiConsumer)` and `BundleInfoJcrHelper.get/storeModuleScriptStatus` are public and OSGi-exported — the status store is reusable without touching Patcher. (Caution: routing scripts through `Patcher.executeScripts` would inherit the 4-segment platform-version gating — we implement our own loop and reuse **only the status store**.) + +### 2.2 What the reference Groovy scripts actually do + +Capability inventory from the linked examples (visibility, templates-system, site-settings-seo, jcontent verified; forms-core is a private repo — inferred from its name only): + +- System sessions per workspace/locale: `JCRTemplate.doExecuteWithSystemSession[AsUser](user, workspace, locale, cb)`, nested cross-workspace sessions. +- Batched SQL-2 iteration: `ScrollableQuery(1000, query)` + `session.save()` + `session.refresh(false)` per batch (site-settings-seo). +- i18n subtleties: `node.getRealNode()` to bypass i18n resolution and touch raw/translation storage (templates-system). +- Node ops: `move`, `markForDeletion`, `Workspace.clone` (live→default restore), `setProperty(name, null)` to remove. +- Definition registry surgery: `NodeTypeRegistry.getAllNodeTypes(source)`, `JCRStoreService.undeployDefinitions(source)` — and even reflection on `ExtendedNodeType.systemId` to re-home definitions (visibility/jcontent). We will _not_ reproduce the reflection hack; U4/U5 need sanctioned wrappers. +- Deferral pattern: site-settings-seo schedules the heavy work as a Quartz `BackgroundJob` to get off the bundle-event thread. + +### 2.3 What the `feature/js-server-extensions` branch gives us + +- **The lifecycle seam**: `Registrar.register(Bundle)` is invoked **exactly once per `BundleEvent.STARTED`** by `JavascriptModuleListener` (`javascript-modules-engine-java/.../JavascriptModuleListener.java:86-99`); registrars are discovered as OSGi services (`@Reference(MULTIPLE, DYNAMIC, GREEDY)`) with replay for already-started bundles. JS top-level code and `bundleInitializer` entries run on **every pooled GraalJS context creation** — _not_ usable for run-once semantics. +- **The registration pattern**: typed wrapper (`registerAction.ts` et al.) → `server.registry.add(type, key, {…adapter})` → per-type registrar reads entries via `graalVMEngine.doWithContext(cp -> cp.getRegistry().find({type, bundleKey}))` (`AbstractServiceRegistrar.java:90-109`). Bridges never cache JS handles. +- **Build**: all `*.server.{ts,tsx,js,jsx}` files are concatenated into a single side-effect init script `dist/server/index.js` (`vite-plugin/src/multi-entry.ts`), shipped via the `Jahia-javascript-InitScript` header. Content patches can ride this — **no new packaging mechanism needed**. +- **Definitions**: CNDs are merged at build time into `META-INF/definitions.cnd` (`jshandler/JavascriptProtocolConnection.java:164-187`); registration is done by core during module deployment, so by the time `STARTED` fires, the **new** version's definitions are registered. +- **The gap**: the typed JCR surface is **read-only**. The java-ts-bind `methodWhitelist` (`javascript-modules-engine-java/.java-ts-bind/package.json`) exposes no `setProperty/addNode/remove/save/move/addMixin/setPrimaryType`. Mutation _works_ at runtime (`HostAccess.ALL`, `GraalVMEngine.java:203`) but is untyped. Also the only session helper is `server.jcr.doExecuteAsGuest` (guest user, live workspace — `js/server/JcrHelper.java:33-46`); content patches need a typed **system-session** helper. + +## 3. Design decision (headline) + +**Engine-managed content patch runner, following the registrar pattern, reusing core's patch-status store.** + +``` +src/**/*.server.ts (build: concatenated into init script) + registerContentPatch({name}, run) ──► server.registry.add("content-patch", name, {…}) + │ +BundleEvent.STARTED (processing server only) ▼ + ContentPatchRegistrar.register(bundle) ──► find pending = entries − recorded + sort by name → execute in doWithContext + │ + ▼ + BundleInfoJcrHelper.storeModuleScriptStatus(symbolicName, + "/javascript/content-patches/" → ".installed" | ".failed" | ".skipped") +``` + +Why this shape: + +- **Once-per-start for free** — the registrar seam is the only true once-per-module-start hook, already battle-tested by the four extension registrars on this branch. +- **Zero core changes** — no `Patcher.setPatchers` fragility (last-writer-wins, no restore on stop), no `.js`-in-`META-INF/patches` ambiguity for Java modules. +- **Ops consistency** — status lands in the _same_ JCR store (`/module-management` → `j:bundlesScripts`) as Groovy module patches, under a distinguishable pseudo-path prefix (`/javascript/content-patches/…` vs `/META-INF/patches/…`). One place to audit; cluster-shared; identical terminal semantics. +- **Same semantics as Groovy where it matters** (once-ever per name, module starts on failure, processing-only), **better DX where Groovy hurts** (typed API, dry-run, CLI reset/force instead of hand-editing JCR JSON). + +Rejected alternatives: + +1. _Contribute a JS `PatchExecutor` to core `Patcher`_ — `setPatchers` is whole-list replacement with no unregistration story; executors receive script content as a string (breaks bundled/imported TS); `.resolved` timing would require evaluating JS from a not-yet-started bundle. Revisit only if Java modules must also ship JS patches. +2. _Separate build entry per content patch_ (e.g. `*.patch.ts` → own dist file + new bundle header) — more moving parts (vite-plugin, transformer, header parsing) for no v1 benefit. The registry already gives us enumeration + lazy execution. Can be added later if init-script size becomes a concern. +3. _Provisioning-based (`.yaml` + custom operation)_ — whiteboard-extensible but wrong UX: not module-versioned, not tracked per module, no TS. + +## 4. Developer experience + +### 4.1 Authoring + +Any `*.server.ts` file (convention: `src/content-patches.server.ts` or `src/content-patches/.server.ts`): + +```ts +import { registerContentPatch } from "@jahia/javascript-modules-library"; + +registerContentPatch( + { + name: "2.0.0-01-remove-legacy-color", // unique, stable, sortable — this IS the identity + description: "Drop mymod:banner `color` values (property removed from CND in 2.0.0)", + }, + ({ patch, jcr, log, dryRun, skip }) => { + // … + }, +); +``` + +Two-arg `(declaration, run)` shape matches `registerAction` & friends. **Content patches are synchronous** — no promises, consistent with actions ("must return their result") and the GraalJS execution model; a returned thenable fails fast with a clear error. All underlying JCR calls block anyway. + +Rules (enforced/linted, see §4.4): + +- `name` is the run-once key — **never rename or reorder a released content patch**; ship a new one instead (exactly like Groovy filename discipline). +- Recommended pattern: `--` (3-segment version + 2-digit counter), matching the Groovy convention. Execution order = lexicographic sort of names within the module. +- Result contract: returns normally → `.installed`; throws → `.failed` (logged, module still starts, remaining content patches for the module are **not** run); `skip("reason")` → `.skipped` (terminal, like Groovy). + +### 4.2 The five use cases as guard-railed one-liners (`patch.*`) + +```ts +// U1 — remove property + clean values (handles i18n translation nodes, both workspaces, batching) +patch.removePropertyValues({ nodeType: "mymod:banner", property: "color" }); + +// U2 — backfill a default on existing content +patch.setPropertyValues({ + nodeType: "mymod:banner", + property: "theme", + onlyIfMissing: true, + value: "light", // or (node) => computed per node +}); + +// U3 — change data type + convert values +patch.convertPropertyValues({ + nodeType: "mymod:banner", + property: "priority", + convert: (value) => Number.parseInt(value.getString(), 10), // JCRValueWrapper in, new value out +}); + +// U4 — delete a definition programmatically +patch.removeNodeType({ + nodeType: "mymod:legacyBanner", + ifContentExists: "fail", // default; or "delete" to purge instances first +}); + +// U5 — rename a definition + rebind existing items +patch.changeNodeType({ + from: "mymod:oldBanner", + to: "mymod:banner", // to-type must exist in the new CND + mapProperties: { legacyTitle: "jcr:title" }, // optional per-property renames + removeOldDefinition: true, +}); +``` + +Shared guard rails baked into every `patch.*` helper: + +- Iterates **`default` and `live`** by default (`workspaces` option to override) — Groovy scripts patch live directly, bypassing publication; we do the same and document it. +- **Batched**: SQL-2 iteration with keyset/scrollable pagination, `session.save()` + `session.refresh(false)` every `batchSize` (default 100), progress logged per batch. (This is exactly what site-settings-seo hand-rolled.) +- **i18n-aware**: internationalized properties (detected via `ExtendedPropertyDefinition.isInternationalized`) are handled on translation subnodes (`getRealNode()` semantics) across all locales. +- **Graceful no-ops**: querying a node type that was never registered on this instance (fresh install) skips with a log line instead of throwing `InvalidQueryException`; missing properties are skipped, not errors. +- **Idempotent by construction** — safe to re-run after a mid-way failure (batches already committed are no-ops on retry). +- `dryRun` — helpers count/log intended changes and discard instead of saving (see §6 tooling). + +### 4.3 Imperative escape hatch + +For everything else (the forms-core-style ACL fix, node moves, cross-workspace repairs): + +```ts +registerContentPatch({ name: "2.1.0-01-fix-upload-permissions" }, ({ jcr, log }) => { + jcr.withSystemSession({ workspace: "default", locale: null }, (session) => { + const node = session.getNode("/sites/mysite/files/uploads"); + node.setProperty("j:inherit", false); // typed once whitelist is extended (§5) + session.save(); + }); + + jcr.forEachNode( + { query: "SELECT * FROM [mymod:banner]", workspaces: ["default", "live"], batchSize: 100 }, + (node) => { + node.getRealNode().setProperty("color", null); + }, + ); // same batching/saving/progress engine the migrate.* helpers use +}); +``` + +- `jcr.withSystemSession` → new typed Java helper (`JcrHelper.doExecuteAsSystem(workspace, locale, cb)`) next to the existing `doExecuteAsGuest`. +- `jcr.forEachNode` → the shared batching engine, exposed directly. +- Full raw access remains available via `server.osgi.getService(...)` (untyped, unchanged). + +### 4.4 Guard rails summary + +| Rail | Mechanism | +| ------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Autocomplete on mutations | extend java-ts-bind `methodWhitelist` (§5) | +| Can't run twice / cluster-safe | JCR status store, processing-server-only registrar | +| Can't hammer the repo | enforced batching + save/refresh cycles | +| Can't silently skip i18n values | helpers enumerate translation nodes | +| Fresh-install safety | type-existence checks → graceful skip | +| Naming discipline | dev-time validation in `registerContentPatch` (shape + duplicate detection) + registrar warning on non-sortable names | +| Failure blast radius | per-module halt on first failure; module still starts; status recorded; `.failed` visible via CLI/GraphQL | +| Recoverability | `reset`/`force` via CLI/GraphQL (vs Groovy's hand-editing of `j:bundlesScripts`) | + +Deliberately **not** in v1: platform-version gating (the 4-segment trap doesn't apply to us), `keep`/run-always results (use dev-mode `force` instead), automatic rollback (JCR has no cross-save transactions — we document "content patches must be idempotent" instead), `.resolved`-equivalent pre-start phase (see Risks). + +## 5. Typed mutation surface (prerequisite work) + +The `methodWhitelist` in `javascript-modules-engine-java/.java-ts-bind/package.json` currently exposes only `get*/has*/is*`. Add (types-only change — runtime already permits via `HostAccess.ALL`): + +- `JCRNodeWrapper`: `setProperty` (relevant overloads), `addNode`, `remove`, `addMixin`, `removeMixin`, `setPrimaryType`, `getRealNode`, `markForDeletion`, `rename`, `orderBefore`, `revokeAllRoles`/`grantRoles`/`denyRoles` (ACL cases) +- `JCRSessionWrapper`: `save`, `refresh`, `move`, `getWorkspace`, `logout` (already partial) +- `JCRPropertyWrapper`: `setValue`, `remove` +- `JCRWorkspaceWrapper`: `clone`, `move` + +This also unblocks **actions** (already on this branch), which today must mutate untyped — worth extracting as its own PR/ADR. Security posture unchanged: typing ≠ capability (capability was already `HostAccess.ALL`). + +Second small prerequisite: extend the vite-plugin **default server glob** from `**/*.server.{jsx,tsx}` to `**/*.server.{js,jsx,ts,tsx}` — plain-TS server files (content patches, but equally actions/choicelists) currently require a manual `inputGlob` override, which is why jahia-test-module overrides it to `react/server/**/*.{ts,tsx}`. + +## 6. Tooling & dev loop + +- **`autoRun` engine setting** (default `true`): production keeps Groovy parity — content patches run automatically at module start. Dev/staging set `false` → the registrar only logs what's pending and defers to CLI/GraphQL. This is what makes `--dry-run` genuinely usable _before_ first execution (with autoRun on, deploying is executing). +- **GraphQL admin extension** (engine-provided, admin-permission-gated): + - `javascriptModules.contentPatches { module, name, status }` — read from the status store + registry (shows `pending` for registered-but-not-recorded). + - mutations: `runPending(module, dryRun)`, `run(module, name, force, dryRun)`, `reset(module, name)`. +- **CLI** (alongside `jahia-deploy` in `vite-plugin/bin/`, same env/credentials): `npx jahia content-patches status | create | run [--name n] [--no-dry-run] [--force] [--yes] | reset --name n`. **`run` is a dry-run by default** (Sanity-inspired, §12); applying requires `--no-dry-run` and a confirmation prompt (`--yes` skips it for CI). `create` scaffolds `src/content-patches/--.server.ts` with the next `NN`. +- **Dev loop**: `yarn watch` redeploys the module; already-recorded content patches don't re-run (correct prod semantics). While iterating on a content patch: `npx jahia content-patches run --name --force --dry-run` until happy, then `--force` for real, then `reset` + fresh content for a final clean run. Documented as the standard recipe. +- **Observability**: per-content-patch log lines (start / per-batch progress / result + duration) under a dedicated logger (`org.jahia.modules.javascript.modules.engine.contentpatches`). + +## 7. Testing strategy + +- **Unit (vitest, no Jahia)**: `convert`/`value` callbacks are pure and trivially testable. Stretch: `@jahia/javascript-modules-library/testing` fake that records `patch.*` calls for asserting content-patch _structure_. +- **Java unit tests**: `ContentPatchRegistrar` covered like `AbstractServiceRegistrarTest` (pending-set computation, ordering, halt-on-failure, status-store writes mocked). +- **E2E (existing Cypress infra in `tests/`)** — the real proof, and the template module developers copy: + 1. Build `jahia-test-module` twice (version override at pack time): v1 with old CND, v2 with new CND + content patches. + 2. Provision v1 → create fixture content via `cy.apollo` (jcr addNode mutations). + 3. Deploy v2 tgz via provisioning API → poll content patch status via GraphQL. + 4. Assert: values removed/backfilled/converted (U1-U3), old type gone / content rebound (U4-U5), status `.installed`, **rerun-safety** (redeploy v2 → nothing re-runs), and publication status of touched nodes stays sane (both-workspace writes — see §12 publish-state row). + 5. Negative specs: throwing content patch → status `.failed`, module still serves views, subsequent content patch not run; `reset` + `run` recovers; `--dry-run` leaves content untouched. +- **Docs**: a "write your first content patch" guide in `docs/2-guides/` with the U1-U5 recipes, plus the testing recipe (two-version deploy) module authors can replicate. + +## 8. Delivery phases + +| Phase | Content | Est. | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| **0 — Spikes** | (a) confirm CND registration completes before `STARTED` on _upgrade_ for JS modules; (b) `setPrimaryType` at scale incl. i18n/version history; (c) write/read `j:bundlesScripts` via `BundleInfoJcrHelper` from the engine bundle; (d) whitelist extension → java-ts-bind regen sanity | 2-3 d | +| **1 — Runtime MVP** | `registerContentPatch` wrapper + types (sync contract); `content-patch` registry type; `ContentPatchRegistrar` (processing-only, sort, pending-set, execute in `doWithContext`, status store, halt-on-failure, `autoRun` setting); typed `withSystemSession` + `forEachNode`; whitelist extension; vite-plugin default-glob extension; test-module fixture + first e2e (U1 imperative) | 4-6 d | +| **2 — Guard-railed helpers** | `patch.removePropertyValues / setPropertyValues / convertPropertyValues / changeNodeType / removeNodeType` with i18n + batching + graceful no-ops; `dryRun`; unit tests; e2e for U1-U5 | 4-6 d | +| **3 — Tooling & docs** | GraphQL admin extension; `jahia content-patches` CLI (dry-run-by-default `run`, `create` scaffolding, `status`, `reset`); create-module template snippet; docs guide + ADR 0006 (+0007 for whitelist) | 3-5 d | +| **4 — Optional/later** | background execution (Quartz deferral à la site-settings-seo); rich report node (timestamps, durations, log tail); vitest recorder fake (Q5); `patch.extractToReference` + `content-patches validate` sweep + publish-state options (§12); pre-start phase if a real U4-style cross-module case demands it; separate build entries if init-script size hurts | on demand | + +Phases 1-3 ≈ **3 weeks** of focused work. Phase 1 alone is a usable (imperative-only) MVP. + +## 9. Risks & mitigations + +- **Definitions timing on upgrade** (spike 0a): if core registers the new CND _after_ `STARTED` in some path, U5's `to`-type could be missing → registrar re-checks type existence and defers with clear error. Spike decides. +- **Bundle-event thread blocking**: content patches run synchronously in the OSGi event dispatch (same as Groovy `.started`). Mitigation: enforced batching bounds memory, docs recommend background phase-4 option for very large repos; log duration. +- **No checksum on names** (Groovy parity): an edited-but-same-name content patch silently won't re-run. Mitigation: docs discipline + CLI `status` could later surface a hash mismatch (phase 4). +- **`removeNodeType` limits**: unregistering node types live has known constraints (registry + Jackrabbit nodetype store). Spike in phase 2; worst case U4 documents "requires content removal + falls back to `undeployDefinitions` semantics". +- **Status-store writes**: single JSON property on `/module-management`, also written by the extender. Writes are processing-server-only and per-bundle-event (effectively serialized); do read-modify-write per content patch, matching extender behavior. + +## 10. Decisions (resolved 2026-07-21) + +1. **Fresh-install baseline**: ✅ Groovy parity — content patches also run on a module's first install; helpers no-op gracefully (type-existence checks). Covers the "module reinstalled but content survived" edge. +2. **Failure policy**: ✅ Groovy parity — log, record `.failed`, module still starts, later content patches for that module held back. (`required: true` to block module start stays a phase-4 idea.) +3. **Workspace default**: ✅ `["default", "live"]` for all `patch.*` helpers and `jcr.forEachNode`, overridable per call. +4. **U4/U5 scope**: ✅ a module's **own definitions only** — content purge/rebind + `undeployDefinitions` for types this module registered; no cross-module re-homing (the visibility/jcontent `systemId` reflection pattern stays out of scope). +5. **Testing scope in v1**: ✅ two-version Cypress/docker e2e recipe only (module devs run docker in CI — Jahia standard); the vitest recorder fake moves to phase 4. Both levels explained in [CONTENT-PATCHES-DEMO.md](CONTENT-PATCHES-DEMO.md) §9. +6. **Branch strategy**: ✅ implement directly on `feature/js-server-extensions`. + +## 11. Assumptions + +- Jahia 8.2.x floor (core facts verified on 8.2.3 sources; status-store API `BundleInfoJcrHelper` is exported there). +- Content patches are for **JS modules only** — no ambition to run TS patches for Java modules (that would force the core-`Patcher` route rejected in §3). +- Content volumes up to ~10⁵-10⁶ nodes per type are in scope via batching; larger repos → phase-4 background mode. +- Publication semantics: patching `live` directly (like every reference Groovy script) is acceptable; no auto-publish step. +- The forms-core example (private repo) is ACL-shaped; covered by the escape hatch + ACL methods in the whitelist, not a dedicated `patch.*` helper. + +## 12. Prior art: Contentful & Sanity (added 2026-07-21) + +Sources: `contentful-migration` README + Contentful CLI `space migration` docs (contentful.com itself rate-limited fetches — plan-rendering details flagged accordingly); sanity.io "Schema and content migrations" + CLI migration reference. + +**Philosophy check.** Two models exist. Contentful: the schema _lives in the CMS_ and migrations mutate it imperatively (`createContentType`, `editContentType`, `changeFieldId`, `moveField`, …) — the migration history _is_ the schema. Sanity: the schema _is code_ (`defineType`), deployed like any code change; migrations (`defineMigration` + `at()/set()/setIfMissing()/unset()` patches) only reconcile content. **Jahia JS modules are the Sanity model** — the CND is the declarative source of truth shipped with the module, and migrations reconcile content — which independently validates this plan's shape. With one notable difference in our favor: **neither product has built-in run-once tracking**. Contentful's documented pattern is a hand-rolled `versionTracking` content type driven by CI ordering; Sanity has no ledger at all (idempotency discipline + dry-run). Our JCR status store + module lifecycle gives run-once, ordering and halt-on-failure out of the box. + +| Dimension | Contentful | Sanity | This plan | +| ------------------------ | -------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| Schema owner | CMS, mutated by migrations | Code (`defineType`) | Code (CND in module) | +| Declaration | `module.exports = fn(migration, ctx)` (TS types available) | `defineMigration({title, documentTypes, filter, migrate})` | `registerContentPatch(declaration, run)` | +| Run-once tracking | none built-in (CI + `versionTracking` pattern) | none (idempotency discipline) | **built-in** (JCR status store, keyed by name) | +| Ordering | CI discipline | none | lexicographic within module + halt-on-failure | +| Selection | per content type only | `documentTypes` + GROQ `filter` | `nodeType`/subtypes + `scope` + `where` SQL-2 fragment + full-query escape hatch | +| Type rename / conversion | `transformEntriesToType`, `changeFieldId` (copy-based, ref rewiring) | **impossible** (`_type`/`_id` immutable → export/edit/reimport) | `changeNodeType` — JCR `setPrimaryType` rebinds in place | +| Extract-to-reference | `deriveLinkedEntries` (with `identityKey` dedup) | manual | phase-4 candidate `patch.extractToReference` | +| Dry-run | none — plan + confirmation prompt (`--yes` for CI) | **default**; `--no-dry-run` to apply; `--from-export` offline dry-runs | **adopted**: CLI dry-run by default (§6) | +| Publish/draft state | `shouldPublish: 'preserve'` on bulk transforms | n/a | default+live writes; publication-status of touched nodes verified in phase-2 e2e | +| Scaffolding | none | `sanity migration create` (templates) | **adopted**: `jahia content-patches create ` | +| Batching / limits | `requestBatchSize` (100), `retryLimit` vs CMA rate limits | transactions + `--concurrency 1-10` | `batchSize` save/refresh cycles — server-side, no rate-limit machinery needed | +| Rollback | none (environments + alias flip) | none (backup first) | none (documented; helpers idempotent by construction) | +| Sandbox / testing | sandbox environments cloned from master | dataset copy/export; offline `--from-export` | docker two-version e2e recipe; staging with `autoRun=false` | +| Validation | pre-apply validation w/ line-accurate errors (chained API) | `sanity documents validate` (per-doc markers + Studio deep links) | registration-time name checks; phase-4 candidate `content-patches validate` sweep | + +**Adopted into this plan** (edits applied to §6/§8 and the demo): + +1. **CLI dry-run by default** (Sanity's standout guard rail): `jahia content-patches run` performs a dry-run; applying requires `--no-dry-run` plus a confirmation prompt (`--yes` for CI). Production `autoRun` at module start is unchanged (Groovy parity — a server upgrade can't pause for a prompt). +2. **Scaffolding** (Sanity): `jahia content-patches create ` generates `src/content-patches/--.server.ts`, computing the next `NN` from existing files — encodes the naming discipline instead of documenting it. +3. **`where` filter** (analog of Sanity's GROQ `filter`): optional SQL-2 constraint fragment on `NodeSelection` (e.g. `where: "price > 1000"`), sitting between plain `nodeType` selection and the full-query escape hatch. + +**Phase-4 candidates noted, not committed**: `patch.extractToReference` (Contentful `deriveLinkedEntries` analog — inline sub-content → referenced node with dedup), a `content-patches validate` content-vs-definitions sweep with per-node report (Sanity `documents validate` analog), publish-state preservation options (Contentful `shouldPublish: 'preserve'` analog). + +**Deliberately ahead**: built-in run-once/ordering; in-place type rename (JCR `setPrimaryType` — Sanity can't at all, Contentful copies + rewires); execution server-side next to the repository (no rate limits, no client credentials). **Accepted gaps**: Contentful environments give a cheap sandbox-and-alias-flip "rollback" Jahia lacks (ours: staging servers + backups); Sanity's `--from-export` offline dry-run has no cheap equivalent (ours needs a running Jahia); Contentful's editor-UI-as-schema operations (field controls, layouts, sidebar widgets) are out of scope — Jahia covers that surface with content-editor forms definitions, not migrations. diff --git a/docs/2-guides/6-content-patches/README.md b/docs/2-guides/6-content-patches/README.md new file mode 100644 index 00000000..a73c8c5e --- /dev/null +++ b/docs/2-guides/6-content-patches/README.md @@ -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 `"--"` 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). + +```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. diff --git a/docs/adr/0006-javascript-content-patches.md b/docs/adr/0006-javascript-content-patches.md new file mode 100644 index 00000000..93b18fc0 --- /dev/null +++ b/docs/adr/0006-javascript-content-patches.md @@ -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/` 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. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 00000000..089fc337 --- /dev/null +++ b/docs/adr/README.md @@ -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 | diff --git a/jahia-test-module/settings/definitions.cnd b/jahia-test-module/settings/definitions.cnd index 11fcce95..fd0d690a 100644 --- a/jahia-test-module/settings/definitions.cnd +++ b/jahia-test-module/settings/definitions.cnd @@ -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) diff --git a/jahia-test-module/src/react/server/extensions/contentPatches.ts b/jahia-test-module/src/react/server/extensions/contentPatches.ts new file mode 100644 index 00000000..47585c8f --- /dev/null +++ b/jahia-test-module/src/react/server/extensions/contentPatches.ts @@ -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(); + }); + }, +); diff --git a/javascript-modules-engine-java/.java-ts-bind/package.json b/javascript-modules-engine-java/.java-ts-bind/package.json index 1fd7be06..0b8e10fe 100644 --- a/javascript-modules-engine-java/.java-ts-bind/package.json +++ b/javascript-modules-engine-java/.java-ts-bind/package.json @@ -29,7 +29,9 @@ "org.jahia.modules.javascript.modules.engine.js.server.RenderHelper", "org.jahia.services.render.RenderContext", "org.jahia.services.render.Resource", - "org.jahia.services.content.JCRNodeWrapper" + "org.jahia.services.content.JCRNodeWrapper", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition", + "org.jahia.services.render.URLResolver" ], "include": [ "java.io.BufferedReader", @@ -92,11 +94,14 @@ "org.jahia.services.content.QueryManagerWrapper", "org.jahia.services.content.decorator.JCRNodeDecorator", "org.jahia.services.content.decorator.JCRSiteNode", + "org.jahia.services.content.nodetypes.ExtendedItemDefinition", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition", "org.jahia.services.query.QueryResultWrapper", "org.jahia.services.query.QueryWrapper", "org.jahia.services.render.RenderContext", "org.jahia.services.render.Resource", "org.jahia.services.render.URLGenerator", + "org.jahia.services.render.URLResolver", "org.jahia.services.sites.JahiaSite", "org.jahia.services.usermanager.JahiaPrincipal", "org.jahia.services.usermanager.JahiaUser", @@ -146,6 +151,7 @@ "java.util.List.iterator", "java.util.List.size", "java.util.Locale.get.*", + "java.util.Locale.toLanguageTag", "java.util.Locale.toString", "java.util.Map.containsKey", "java.util.Map.get.*", @@ -161,6 +167,9 @@ "javax.jcr.Binary.is.*", "javax.jcr.Item.get.*", "javax.jcr.Item.is.*", + "javax.jcr.Item.remove", + "javax.jcr.Node.addMixin", + "javax.jcr.Node.addNode.*", "javax.jcr.Node.get.*AsDate", "javax.jcr.Node.get.*Url.*", "javax.jcr.Node.get.*User", @@ -186,12 +195,19 @@ "javax.jcr.Node.getWeakReferences.*", "javax.jcr.Node.has.*", "javax.jcr.Node.is.*", + "javax.jcr.Node.orderBefore", + "javax.jcr.Node.remove", + "javax.jcr.Node.removeMixin", + "javax.jcr.Node.setPrimaryType", + "javax.jcr.Node.setProperty.*", "javax.jcr.NodeIterator.getSize", "javax.jcr.NodeIterator.hasNext", "javax.jcr.NodeIterator.nextNode", "javax.jcr.Property.get.*", "javax.jcr.Property.has.*", "javax.jcr.Property.is.*", + "javax.jcr.Property.remove", + "javax.jcr.Property.setValue.*", "javax.jcr.PropertyIterator.getSize", "javax.jcr.PropertyIterator.hasNext", "javax.jcr.PropertyIterator.nextProperty", @@ -238,19 +254,25 @@ "org.jahia.services.content.JCRCallback.*", "org.jahia.services.content.JCRItemWrapper.get.*", "org.jahia.services.content.JCRItemWrapper.is.*", + "org.jahia.services.content.JCRItemWrapper.remove", "org.jahia.services.content.JCRNodeIteratorWrapper.getPosition", "org.jahia.services.content.JCRNodeIteratorWrapper.getSize", "org.jahia.services.content.JCRNodeIteratorWrapper.hasNext", "org.jahia.services.content.JCRNodeIteratorWrapper.nextNode", + "org.jahia.services.content.JCRNodeWrapper.addMixin", + "org.jahia.services.content.JCRNodeWrapper.addNode.*", + "org.jahia.services.content.JCRNodeWrapper.denyRoles", "org.jahia.services.content.JCRNodeWrapper.get.*AsDate", "org.jahia.services.content.JCRNodeWrapper.get.*Url.*", "org.jahia.services.content.JCRNodeWrapper.get.*User", "org.jahia.services.content.JCRNodeWrapper.getAncestor.*", + "org.jahia.services.content.JCRNodeWrapper.getApplicablePropertyDefinition", "org.jahia.services.content.JCRNodeWrapper.getCanonicalPath", "org.jahia.services.content.JCRNodeWrapper.getDisplayableName", "org.jahia.services.content.JCRNodeWrapper.getExistingLocales", "org.jahia.services.content.JCRNodeWrapper.getI18N", "org.jahia.services.content.JCRNodeWrapper.getI18Ns", + "org.jahia.services.content.JCRNodeWrapper.getOrCreateI18N", "org.jahia.services.content.JCRNodeWrapper.getIdentifier", "org.jahia.services.content.JCRNodeWrapper.getLanguage", "org.jahia.services.content.JCRNodeWrapper.getMixinNodeTypes", @@ -260,6 +282,7 @@ "org.jahia.services.content.JCRNodeWrapper.getParent", "org.jahia.services.content.JCRNodeWrapper.getPath.*", "org.jahia.services.content.JCRNodeWrapper.getPrimaryNodeTypeName", + "org.jahia.services.content.JCRNodeWrapper.getRealNode", "org.jahia.services.content.JCRNodeWrapper.getProperties.*", "org.jahia.services.content.JCRNodeWrapper.getProperty.*", "org.jahia.services.content.JCRNodeWrapper.getReferences.*", @@ -267,10 +290,25 @@ "org.jahia.services.content.JCRNodeWrapper.getSession", "org.jahia.services.content.JCRNodeWrapper.getUUID", "org.jahia.services.content.JCRNodeWrapper.getWeakReferences.*", + "org.jahia.services.content.JCRNodeWrapper.grantRoles", "org.jahia.services.content.JCRNodeWrapper.has.*", "org.jahia.services.content.JCRNodeWrapper.is.*", + "org.jahia.services.content.JCRNodeWrapper.markForDeletion.*", + "org.jahia.services.content.JCRNodeWrapper.orderBefore", + "org.jahia.services.content.JCRNodeWrapper.remove", + "org.jahia.services.content.JCRNodeWrapper.removeMixin", + "org.jahia.services.content.JCRNodeWrapper.rename", + "org.jahia.services.content.JCRNodeWrapper.revokeRolesForPrincipal", + "org.jahia.services.content.JCRNodeWrapper.setAclInheritanceBreak", + "org.jahia.services.content.JCRNodeWrapper.setPrimaryType", + "org.jahia.services.content.JCRNodeWrapper.setProperty.*", + "org.jahia.services.content.JCRNodeWrapper.unmarkForDeletion", + "org.jahia.services.content.JCRPropertyWrapper.addValue.*", "org.jahia.services.content.JCRPropertyWrapper.get.*", "org.jahia.services.content.JCRPropertyWrapper.is.*", + "org.jahia.services.content.JCRPropertyWrapper.remove", + "org.jahia.services.content.JCRPropertyWrapper.removeValue.*", + "org.jahia.services.content.JCRPropertyWrapper.setValue.*", "org.jahia.services.content.JCRSessionWrapper.getAliasedUser", "org.jahia.services.content.JCRSessionWrapper.getAttribute.*", "org.jahia.services.content.JCRSessionWrapper.getFallbackLocale", @@ -282,13 +320,20 @@ "org.jahia.services.content.JCRSessionWrapper.getRootNode", "org.jahia.services.content.JCRSessionWrapper.getUser.*", "org.jahia.services.content.JCRSessionWrapper.getWorkspace", + "org.jahia.services.content.JCRSessionWrapper.itemExists", + "org.jahia.services.content.JCRSessionWrapper.move.*", "org.jahia.services.content.JCRSessionWrapper.nodeExists", "org.jahia.services.content.JCRSessionWrapper.propertyExists", + "org.jahia.services.content.JCRSessionWrapper.refresh", + "org.jahia.services.content.JCRSessionWrapper.save", "org.jahia.services.content.JCRValueWrapper.get.*", "org.jahia.services.content.JCRValueWrapper.is.*", + "org.jahia.services.content.JCRWorkspaceWrapper.clone", + "org.jahia.services.content.JCRWorkspaceWrapper.copy.*", "org.jahia.services.content.JCRWorkspaceWrapper.getName", "org.jahia.services.content.JCRWorkspaceWrapper.getQueryManager", "org.jahia.services.content.JCRWorkspaceWrapper.getSession", + "org.jahia.services.content.JCRWorkspaceWrapper.move.*", "org.jahia.services.content.QueryManagerWrapper.createQuery", "org.jahia.services.content.decorator.JCRSiteNode.get.*AsDate", "org.jahia.services.content.decorator.JCRSiteNode.get.*InstalledModules.*", @@ -321,6 +366,18 @@ "org.jahia.services.content.decorator.JCRSiteNode.getUUID", "org.jahia.services.content.decorator.JCRSiteNode.getWeakReferences.*", "org.jahia.services.content.decorator.JCRSiteNode.is.*", + "org.jahia.services.content.nodetypes.ExtendedItemDefinition.getName", + "org.jahia.services.content.nodetypes.ExtendedItemDefinition.getSelectorOptions", + "org.jahia.services.content.nodetypes.ExtendedItemDefinition.isHidden", + "org.jahia.services.content.nodetypes.ExtendedItemDefinition.isMandatory", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.getName", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.getRequiredType", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.getSelectorOptions", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.getValueConstraints", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.isHidden", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.isInternationalized", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.isMandatory", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.isMultiple", "org.jahia.services.query.QueryResultWrapper.getApproxCount", "org.jahia.services.query.QueryResultWrapper.getNodes", "org.jahia.services.query.QueryWrapper.bindValue", @@ -336,6 +393,11 @@ "org.jahia.services.render.Resource.is.*", "org.jahia.services.render.URLGenerator.get.*", "org.jahia.services.render.URLGenerator.is.*", + "org.jahia.services.render.URLResolver.getLocale", + "org.jahia.services.render.URLResolver.getPath", + "org.jahia.services.render.URLResolver.getSiteKey", + "org.jahia.services.render.URLResolver.getUrlPathInfo", + "org.jahia.services.render.URLResolver.getWorkspace", "org.jahia.services.usermanager.JahiaUser.get.*", "org.jahia.services.usermanager.JahiaUser.is.*", "org.osgi.framework.Bundle.get.*", @@ -450,7 +512,6 @@ "org.jahia.services.content.decorator.JCRPlaceholderNode", "org.jahia.services.content.decorator.JCRUserNode", "org.jahia.services.content.nodetypes.ExtendedNodeDefinition", - "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition", "org.jahia.services.content.nodetypes.NodeTypeWrapper", "org.jahia.services.pwd.PasswordService", "org.jahia.services.query.QueryResultAdapter", diff --git a/javascript-modules-engine-java/pom.xml b/javascript-modules-engine-java/pom.xml index 6727ba77..287a0bcb 100644 --- a/javascript-modules-engine-java/pom.xml +++ b/javascript-modules-engine-java/pom.xml @@ -109,6 +109,13 @@ jdom2 provided + + + org.json + json + 20231013 + provided + org.jahia.server jahia-impl @@ -208,6 +215,18 @@ JUnitParams test + + org.mockito + mockito-core + 4.11.0 + test + + + + org.graalvm.js + js + test + @@ -316,6 +335,8 @@ io.github.bensku:java-ts-bind + + org.graalvm.js:js org.apache.jackrabbit:jackrabbit-spi-commons org.jboss.spec.javax.servlet:jboss-servlet-api_3.1_spec diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/JcrHelper.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/JcrHelper.java index 7a8cb719..b2eeb8c7 100644 --- a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/JcrHelper.java +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/JcrHelper.java @@ -5,6 +5,7 @@ import org.jahia.services.content.JCRCallback; import org.jahia.services.content.JCRTemplate; import org.jahia.services.usermanager.JahiaUserManagerService; +import org.jahia.utils.LanguageCodeConverters; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,4 +45,31 @@ public Object doExecuteAsGuest(JCRCallback callback, Locale locale, Stri } return result; } + + /** + * Execute JCR operations on a system session (root privileges) on the given workspace and locale. + * This is intended for server-side code that must write to the repository, such as content patch + * scripts. + * + *

Unlike {@link #doExecuteAsGuest}, errors are NOT swallowed: they are rethrown to the caller, + * because callers like the content patch runner must detect failures. + * + * @param callback the callback to execute using the JCR session + * @param language the session language code (e.g. "en"), or null for a non-localized session + * (translation nodes are then visible as plain subnodes, which is usually what + * content patches want) + * @param workspace the workspace to open the session on ("default" or "live") + * @return the result of the callback + */ + public Object doExecuteAsSystem(JCRCallback callback, String language, String workspace) { + Locale locale = language != null ? LanguageCodeConverters.languageCodeToLocale(language) : null; + try { + return JCRTemplate.getInstance().doExecuteWithSystemSessionAsUser(null, workspace, locale, callback); + } catch (Exception e) { + // include the class name: many JCR exceptions (e.g. UnsupportedRepositoryOperationException) + // carry a null message, and the polyglot boundary hides the Java cause chain from JS + throw new IllegalStateException("Error while executing callback as system: " + + e.getClass().getSimpleName() + (e.getMessage() != null ? ": " + e.getMessage() : ""), e); + } + } } diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchRegistrar.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchRegistrar.java new file mode 100644 index 00000000..2d1c968d --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchRegistrar.java @@ -0,0 +1,222 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars.contentpatches; + +import org.graalvm.polyglot.Value; +import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMEngine; +import org.jahia.modules.javascript.modules.engine.registrars.Registrar; +import org.jahia.services.modulemanager.persistence.jcr.BundleInfoJcrHelper; +import org.jahia.settings.SettingsBean; +import org.json.JSONObject; +import org.osgi.framework.Bundle; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.ConfigurationPolicy; +import org.osgi.service.component.annotations.Modified; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.jcr.RepositoryException; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Runs the pending patches of a JavaScript module when the module starts. + * + *

Content patches are JavaScript registry entries of type {@code content patch}, declared through the + * library's {@code registerContentPatch} wrapper. On {@code BundleEvent.STARTED} (the once-per-start + * {@link Registrar} seam), this registrar — on the processing server only — sorts the module's + * patches by name, filters out the ones already recorded in Jahia's module patch status store + * ({@code /module-management} → {@code j:bundlesScripts}, the same store used for Groovy + * {@code META-INF/patches}), and executes the pending ones in order. + * + *

Results mirror the Groovy patcher contract: {@code .installed} (completed), {@code .skipped} + * (the script called {@code skip()}), {@code .failed} (the script threw). All results are terminal: + * a recorded content patch never re-runs, whatever its outcome. On {@code .failed}, the module's + * remaining patches are halted (they stay pending) but the module itself keeps starting — + * failures never break module startup. + * + *

The {@code autoRun} configuration property (PID + * {@code org.jahia.modules.javascript.modules.engine.contentpatches}, default {@code true}) can be set + * to {@code false} on development/staging servers: pending patches are then only logged, and + * execution is deferred to an explicit trigger. + */ +@Component(service = Registrar.class, immediate = true, + configurationPid = ContentPatchRegistrar.CONFIG_PID, configurationPolicy = ConfigurationPolicy.OPTIONAL) +public class ContentPatchRegistrar implements Registrar { + + public static final String REGISTRY_TYPE = "content-patch"; + public static final String CONFIG_PID = "org.jahia.modules.javascript.modules.engine.contentpatches"; + + /** Pseudo-path prefix distinguishing JS patches from Groovy patch entries in the shared status store. */ + public static final String STATUS_PATH_PREFIX = "/javascript/content-patches/"; + + public static final String RESULT_INSTALLED = ".installed"; + public static final String RESULT_SKIPPED = ".skipped"; + public static final String RESULT_FAILED = ".failed"; + + private static final Logger logger = LoggerFactory.getLogger(ContentPatchRegistrar.class); + + protected GraalVMEngine graalVMEngine; + private boolean autoRun = true; + + @Reference(cardinality = ReferenceCardinality.MANDATORY) + public void setGraalVMEngine(GraalVMEngine graalVMEngine) { + this.graalVMEngine = graalVMEngine; + } + + @Activate + @Modified + public void activate(Map props) { + Object value = props.get("autoRun"); + this.autoRun = value == null || Boolean.parseBoolean(value.toString()); + } + + @Override + public void register(Bundle bundle) { + try { + if (!isProcessingServer()) { + return; + } + List> patches = findContentPatches(bundle); + if (patches.isEmpty()) { + return; + } + patches.sort(Comparator.comparing(entry -> String.valueOf(entry.get("name")))); + + String symbolicName = bundle.getSymbolicName(); + JSONObject status = getStatus(symbolicName); + // A recorded failure is a persistent barrier: patches ordered after the first + // .failed one stay held (across restarts and redeploys) until that record is cleared + // and the content patch succeeds — otherwise later patches would run on the next module + // start even though an earlier one never completed. + String failedBarrier = patches.stream() + .map(entry -> String.valueOf(entry.get("name"))) + .filter(name -> RESULT_FAILED.equals(status.optString(STATUS_PATH_PREFIX + name))) + .findFirst().orElse(null); + List> pending = patches.stream() + .filter(entry -> !status.has(STATUS_PATH_PREFIX + entry.get("name"))) + .filter(entry -> failedBarrier == null + || String.valueOf(entry.get("name")).compareTo(failedBarrier) < 0) + .collect(java.util.stream.Collectors.toList()); + if (failedBarrier != null) { + long held = patches.stream() + .filter(entry -> !status.has(STATUS_PATH_PREFIX + entry.get("name"))) + .count() - pending.size(); + if (held > 0) { + logger.warn("Module {} has {} content patch(es) held back behind failed content patch {} — " + + "fix it and clear its record to let them run", symbolicName, held, failedBarrier); + } + } + if (pending.isEmpty()) { + logger.debug("No pending content patch for module {}", symbolicName); + return; + } + if (!autoRun) { + logger.info("Module {} has {} pending content patch(es) but autoRun is disabled — waiting for an explicit trigger", + symbolicName, pending.size()); + return; + } + + logger.info("Running {} pending content patch(es) for module {} {}", pending.size(), symbolicName, + bundle.getVersion()); + for (Map entry : pending) { + String name = String.valueOf(entry.get("name")); + long startTime = System.currentTimeMillis(); + String result = executeContentPatch(bundle, String.valueOf(entry.get("key")), name); + status.put(STATUS_PATH_PREFIX + name, result); + storeStatus(symbolicName, status); + logger.info("ContentPatch {} of module {} finished with result {} in {}ms", name, symbolicName, + result, System.currentTimeMillis() - startTime); + if (RESULT_FAILED.equals(result)) { + logger.error("ContentPatch {} of module {} failed — halting the module's remaining patches " + + "(they stay pending). The module keeps starting.", name, symbolicName); + break; + } + } + } catch (Exception e) { + // Failures here must never prevent the module from starting + logger.error("Unable to run patches of bundle {}", bundle.getSymbolicName(), e); + } + } + + @Override + public void unregister(Bundle bundle) { + // nothing to tear down: patches leave no live service behind + } + + private List> findContentPatches(Bundle bundle) { + return graalVMEngine.doWithContext(contextProvider -> { + Map filter = new HashMap<>(); + filter.put("type", REGISTRY_TYPE); + filter.put("bundleKey", bundle.getSymbolicName()); + return contextProvider.getRegistry().find(filter); + }); + } + + /** + * Executes one content patch inside a GraalVM context, re-resolving the registry entry by key (JS + * handles must not be cached outside a context). Returns the result string to record. + * Protected as a seam for unit tests. + */ + protected String executeContentPatch(Bundle bundle, String key, String name) { + try { + return graalVMEngine.doWithContext(contextProvider -> { + Map entry = contextProvider.getRegistry().get(REGISTRY_TYPE, key); + if (entry == null || entry.get("execute") == null) { + logger.error("ContentPatch {} of module {} is no longer available in the registry", + name, bundle.getSymbolicName()); + return RESULT_FAILED; + } + Value result = Value.asValue(entry.get("execute")).execute(newContentPatchSupport(bundle)); + if (result != null && result.isString()) { + String resultString = result.asString(); + if (RESULT_INSTALLED.equals(resultString) || RESULT_SKIPPED.equals(resultString)) { + return resultString; + } + logger.warn("ContentPatch {} of module {} returned unexpected result '{}', recording {}", + name, bundle.getSymbolicName(), resultString, RESULT_INSTALLED); + } + return RESULT_INSTALLED; + }); + } catch (Exception e) { + logger.error("ContentPatch {} of module {} threw an error", name, bundle.getSymbolicName(), e); + return RESULT_FAILED; + } + } + + // Seams overridable in unit tests + + protected boolean isProcessingServer() { + return SettingsBean.getInstance().isProcessingServer(); + } + + protected JSONObject getStatus(String symbolicName) throws RepositoryException { + return BundleInfoJcrHelper.getModuleScriptsStatus(symbolicName); + } + + protected void storeStatus(String symbolicName, JSONObject status) throws RepositoryException { + BundleInfoJcrHelper.storeModuleScriptStatus(symbolicName, status); + } + + protected ContentPatchSupport newContentPatchSupport(Bundle bundle) { + return new ContentPatchSupport(bundle, false); + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchSupport.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchSupport.java new file mode 100644 index 00000000..be3a7361 --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchSupport.java @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars.contentpatches; + +import org.jahia.services.content.nodetypes.ExtendedNodeType; +import org.jahia.services.content.nodetypes.NodeTypeRegistry; +import org.osgi.framework.Bundle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.jcr.nodetype.ConstraintViolationException; +import javax.jcr.nodetype.NoSuchNodeTypeException; + +/** + * Java support object handed to a JavaScript content patch when it is executed. It carries the + * per-content patch logger, the dry-run flag, module metadata, and the sanctioned definition + * operations a content patch is allowed to perform (restricted to node types owned by the module, + * i.e. whose {@code systemId} is the module's symbolic name). + * + *

The JS side (the {@code registerContentPatch} wrapper in the library) builds the idiomatic + * {@code ContentPatchContext} from this object — keep both shapes in sync. + */ +public class ContentPatchSupport { + + private static final String LOGGER_PREFIX = "org.jahia.modules.javascript.modules.engine.contentpatches."; + + private final Bundle bundle; + private final boolean dryRun; + + public ContentPatchSupport(Bundle bundle, boolean dryRun) { + this.bundle = bundle; + this.dryRun = dryRun; + } + + /** Logger dedicated to one content patch, named {@code …contentpatches..}. */ + public Logger getLogger(String patchName) { + return LoggerFactory.getLogger(LOGGER_PREFIX + bundle.getSymbolicName() + "." + patchName); + } + + public boolean isDryRun() { + return dryRun; + } + + public String getModuleName() { + return bundle.getSymbolicName(); + } + + public String getModuleVersion() { + return bundle.getVersion().toString(); + } + + /** Whether a node type is currently registered on this instance (fresh installs may not have legacy types). */ + public boolean isNodeTypeRegistered(String name) { + return NodeTypeRegistry.getInstance().hasNodeType(name); + } + + /** + * Unregisters a node type OWNED BY THIS MODULE from the node type registry. Types registered by + * another module (different {@code systemId}) are refused — cross-module definition surgery is + * out of scope for JS content patches. + * + * @throws IllegalArgumentException if the type belongs to another module + * @throws IllegalStateException if the registry refuses the removal (e.g. remaining usages) + */ + public void unregisterNodeType(String name) { + 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; + } + try { + registry.unregisterNodeType(name); + } catch (ConstraintViolationException e) { + throw new IllegalStateException("Unable to unregister node type " + name + ": " + e.getMessage(), e); + } + } +} diff --git a/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchRegistrarTest.java b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchRegistrarTest.java new file mode 100644 index 00000000..4e177032 --- /dev/null +++ b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchRegistrarTest.java @@ -0,0 +1,239 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars.contentpatches; + +import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMEngine; +import org.json.JSONObject; +import org.junit.Before; +import org.junit.Test; +import org.osgi.framework.Bundle; +import org.osgi.framework.Version; + +import javax.jcr.RepositoryException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static org.jahia.modules.javascript.modules.engine.registrars.contentpatches.ContentPatchRegistrar.RESULT_FAILED; +import static org.jahia.modules.javascript.modules.engine.registrars.contentpatches.ContentPatchRegistrar.RESULT_INSTALLED; +import static org.jahia.modules.javascript.modules.engine.registrars.contentpatches.ContentPatchRegistrar.RESULT_SKIPPED; +import static org.jahia.modules.javascript.modules.engine.registrars.contentpatches.ContentPatchRegistrar.STATUS_PATH_PREFIX; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ContentPatchRegistrarTest { + + private GraalVMEngine engine; + private Bundle bundle; + private TestableContentPatchRegistrar registrar; + private List> registryEntries; + + /** Overrides the JCR/JS seams: canned execution results, in-memory status store. */ + private static class TestableContentPatchRegistrar extends ContentPatchRegistrar { + final Map results = new HashMap<>(); + final List executed = new ArrayList<>(); + final List storeCalls = new ArrayList<>(); + JSONObject status = new JSONObject(); + boolean processingServer = true; + boolean failOnGetStatus = false; + + @Override + protected boolean isProcessingServer() { + return processingServer; + } + + @Override + protected JSONObject getStatus(String symbolicName) throws RepositoryException { + if (failOnGetStatus) { + throw new RepositoryException("status store unavailable"); + } + return status; + } + + @Override + protected void storeStatus(String symbolicName, JSONObject status) { + storeCalls.add(status.toString()); + } + + @Override + protected String executeContentPatch(Bundle bundle, String key, String name) { + executed.add(name); + return results.getOrDefault(name, RESULT_INSTALLED); + } + } + + @Before + public void setUp() { + engine = mock(GraalVMEngine.class); + bundle = mock(Bundle.class); + when(bundle.getSymbolicName()).thenReturn("test-bundle"); + when(bundle.getVersion()).thenReturn(new Version("2.0.0")); + + registryEntries = new ArrayList<>(); + when(engine.doWithContext(any(Function.class))).thenAnswer(invocation -> registryEntries); + + registrar = new TestableContentPatchRegistrar(); + registrar.setGraalVMEngine(engine); + registrar.activate(Collections.emptyMap()); + } + + private Map patchEntry(String name) { + Map entry = new HashMap<>(); + entry.put("type", ContentPatchRegistrar.REGISTRY_TYPE); + entry.put("key", "test-bundle_content-patch_" + name); + entry.put("bundleKey", "test-bundle"); + entry.put("name", name); + return entry; + } + + @Test + public void runsPendingContentPatchsInNameOrderAndRecordsResults() { + // registered out of order on purpose: execution must follow name order + registryEntries.addAll(Arrays.asList(patchEntry("2.0.0-02-second"), patchEntry("2.0.0-01-first"))); + + registrar.register(bundle); + + assertEquals(Arrays.asList("2.0.0-01-first", "2.0.0-02-second"), registrar.executed); + assertEquals(RESULT_INSTALLED, registrar.status.getString(STATUS_PATH_PREFIX + "2.0.0-01-first")); + assertEquals(RESULT_INSTALLED, registrar.status.getString(STATUS_PATH_PREFIX + "2.0.0-02-second")); + // status persisted after each content patch, not only at the end + assertEquals(2, registrar.storeCalls.size()); + } + + @Test + public void alreadyRecordedContentPatchsNeverRunAgain() { + registryEntries.addAll(Arrays.asList(patchEntry("2.0.0-01-first"), patchEntry("2.0.0-02-second"))); + registrar.status.put(STATUS_PATH_PREFIX + "2.0.0-01-first", RESULT_INSTALLED); + + registrar.register(bundle); + + assertEquals(Collections.singletonList("2.0.0-02-second"), registrar.executed); + } + + @Test + public void failedContentPatchsAreRecordedAsTerminal() { + registryEntries.add(patchEntry("2.0.0-01-first")); + registrar.status.put(STATUS_PATH_PREFIX + "2.0.0-01-first", RESULT_FAILED); + + registrar.register(bundle); + + assertTrue(registrar.executed.isEmpty()); + } + + @Test + public void failureHaltsTheModulesRemainingContentPatchs() { + registryEntries.addAll(Arrays.asList(patchEntry("2.0.0-01-first"), patchEntry("2.0.0-02-second"))); + registrar.results.put("2.0.0-01-first", RESULT_FAILED); + + registrar.register(bundle); + + assertEquals(Collections.singletonList("2.0.0-01-first"), registrar.executed); + assertEquals(RESULT_FAILED, registrar.status.getString(STATUS_PATH_PREFIX + "2.0.0-01-first")); + assertFalse(registrar.status.has(STATUS_PATH_PREFIX + "2.0.0-02-second")); + } + + @Test + public void recordedFailureIsAPersistentBarrierAcrossRestarts() { + // simulates the NEXT module start after a failure: 01 is recorded .failed, 02 never ran + registryEntries.addAll(Arrays.asList(patchEntry("2.0.0-01-first"), patchEntry("2.0.0-02-second"))); + registrar.status.put(STATUS_PATH_PREFIX + "2.0.0-01-first", RESULT_FAILED); + + registrar.register(bundle); + + assertTrue("02 must stay held behind the failed 01", registrar.executed.isEmpty()); + assertFalse(registrar.status.has(STATUS_PATH_PREFIX + "2.0.0-02-second")); + } + + @Test + public void clearingAFailedRecordReleasesTheHeldContentPatchs() { + registryEntries.addAll(Arrays.asList(patchEntry("2.0.0-01-first"), patchEntry("2.0.0-02-second"))); + // the failed record was cleared (the documented recovery), 01 re-runs then 02 follows + registrar.register(bundle); + + assertEquals(Arrays.asList("2.0.0-01-first", "2.0.0-02-second"), registrar.executed); + } + + @Test + public void skippedIsTerminalButDoesNotHalt() { + registryEntries.addAll(Arrays.asList(patchEntry("2.0.0-01-first"), patchEntry("2.0.0-02-second"))); + registrar.results.put("2.0.0-01-first", RESULT_SKIPPED); + + registrar.register(bundle); + + assertEquals(Arrays.asList("2.0.0-01-first", "2.0.0-02-second"), registrar.executed); + assertEquals(RESULT_SKIPPED, registrar.status.getString(STATUS_PATH_PREFIX + "2.0.0-01-first")); + } + + @Test + public void groovyPatchEntriesInTheSharedStoreAreLeftUntouched() { + registryEntries.add(patchEntry("2.0.0-01-first")); + registrar.status.put("/META-INF/patches/groovy/foo.started.groovy", ".installed"); + + registrar.register(bundle); + + assertEquals(Collections.singletonList("2.0.0-01-first"), registrar.executed); + assertEquals(".installed", registrar.status.getString("/META-INF/patches/groovy/foo.started.groovy")); + } + + @Test + public void autoRunDisabledLeavesContentPatchsPending() { + Map props = new HashMap<>(); + props.put("autoRun", "false"); + registrar.activate(props); + registryEntries.add(patchEntry("2.0.0-01-first")); + + registrar.register(bundle); + + assertTrue(registrar.executed.isEmpty()); + assertTrue(registrar.storeCalls.isEmpty()); + } + + @Test + public void nonProcessingServersExecuteNothing() { + registrar.processingServer = false; + registryEntries.add(patchEntry("2.0.0-01-first")); + + registrar.register(bundle); + + assertTrue(registrar.executed.isEmpty()); + } + + @Test + public void statusStoreErrorsNeverPreventModuleStart() { + registrar.failOnGetStatus = true; + registryEntries.add(patchEntry("2.0.0-01-first")); + + registrar.register(bundle); // must not throw + + assertTrue(registrar.executed.isEmpty()); + } + + @Test + public void modulesWithoutContentPatchsAreANoOp() { + registrar.register(bundle); + + assertTrue(registrar.executed.isEmpty()); + assertTrue(registrar.storeCalls.isEmpty()); + } +} diff --git a/javascript-modules-library/src/framework/contentPatches/jcr.ts b/javascript-modules-library/src/framework/contentPatches/jcr.ts new file mode 100644 index 00000000..2469f180 --- /dev/null +++ b/javascript-modules-library/src/framework/contentPatches/jcr.ts @@ -0,0 +1,145 @@ +import type { JCRCallback, JCRNodeWrapper, JCRSessionWrapper } from "org.jahia.services.content"; +import type { + ContentPatchJcr, + ContentPatchLogger, + ContentPatchOperationReport, + NodeSelection, + QuerySelection, +} from "./types.js"; + +/** Page size used when snapshotting the identifiers of the nodes to process. */ +const SNAPSHOT_PAGE_SIZE = 1000; + +/** Thrown by `context.skip()`; caught by the `execute` adapter to record `.skipped`. */ +export class ContentPatchSkipped extends Error { + constructor(public readonly reason: string) { + super(reason); + this.name = "ContentPatchSkipped"; + } +} + +const escapeSql2Literal = (value: string) => value.replace(/'/g, "''"); + +const buildQuery = (options: NodeSelection): string => { + const clauses: string[] = []; + if (options.scope) { + clauses.push(`ISDESCENDANTNODE(n, '${escapeSql2Literal(options.scope)}')`); + } + if (options.where) { + clauses.push(`(${options.where})`); + } + return `SELECT * FROM [${options.nodeType}] AS n${ + clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "" + }`; +}; + +/** Builds the `jcr` part of the content patch context. */ +export const createContentPatchJcr = ( + dryRun: boolean, + log: ContentPatchLogger, +): ContentPatchJcr => { + const withSystemSession = ( + { + workspace = "default", + locale = null, + }: { workspace?: "default" | "live"; locale?: string | null }, + callback: (session: JCRSessionWrapper) => T, + ): T => + server.jcr.doExecuteAsSystem( + ((session: JCRSessionWrapper) => callback(session)) as unknown as JCRCallback, + // the Java side accepts null (non-localized session) but the generated type says string + locale as unknown as string, + workspace, + ) as T; + + const forEachNode = ( + options: NodeSelection | QuerySelection, + callback: (node: JCRNodeWrapper) => boolean | void, + ): ContentPatchOperationReport => { + const workspaces = options.workspaces ?? ["default", "live"]; + const batchSize = options.batchSize ?? 100; + const query = "query" in options ? options.query : buildQuery(options); + const primaryTypeOnly = + "query" in options || (options.includeSubtypes ?? true) ? null : options.nodeType; + + const report: ContentPatchOperationReport = { + matched: 0, + updated: 0, + skipped: 0, + byWorkspace: {}, + }; + + for (const workspace of workspaces) { + const counters = { matched: 0, updated: 0, skipped: 0 }; + report.byWorkspace[workspace] = counters; + + // Snapshot the identifiers first: mutating nodes while paging through a live query result + // (retyping, removing…) would make the pagination skip or repeat nodes. + const identifiers = withSystemSession({ workspace }, (session) => { + const result: string[] = []; + for (let offset = 0; ; offset += SNAPSHOT_PAGE_SIZE) { + const sql2Query = session.getWorkspace().getQueryManager().createQuery(query, "JCR-SQL2"); + sql2Query.setLimit(SNAPSHOT_PAGE_SIZE); + sql2Query.setOffset(offset); + const iterator = sql2Query.execute().getNodes(); + let pageCount = 0; + while (iterator.hasNext()) { + result.push((iterator.nextNode() as JCRNodeWrapper).getIdentifier()); + pageCount++; + } + if (pageCount < SNAPSHOT_PAGE_SIZE) break; + } + return result; + }); + counters.matched = identifiers.length; + + // Process in batches, one short-lived system session per batch. In dry-run mode the batch is + // discarded (refresh(false)) instead of saved, so callbacks can mutate freely either way. + for (let start = 0; start < identifiers.length; start += batchSize) { + const batch = identifiers.slice(start, start + batchSize); + withSystemSession({ workspace }, (session) => { + for (const identifier of batch) { + let node: JCRNodeWrapper; + try { + node = session.getNodeByIdentifier(identifier); + } catch { + // Vanished since the snapshot (e.g. removed along with its parent) — fine. + counters.skipped++; + continue; + } + if (primaryTypeOnly !== null && node.getPrimaryNodeTypeName() !== primaryTypeOnly) { + counters.skipped++; + continue; + } + if (callback(node) === false) { + counters.skipped++; + } else { + counters.updated++; + } + } + if (dryRun) { + session.refresh(false); + } else { + session.save(); + } + }); + log.debug( + `${workspace}: processed ${Math.min(start + batchSize, identifiers.length)}/${identifiers.length} nodes`, + ); + } + + log.info( + `${dryRun ? "[dry-run] " : ""}${workspace}: ${counters.matched} matched, ${counters.updated} ${ + dryRun ? "would be updated" : "updated" + }, ${counters.skipped} skipped`, + ); + report.matched += counters.matched; + report.updated += counters.updated; + report.skipped += counters.skipped; + } + + return report; + }; + + return { withSystemSession, forEachNode }; +}; diff --git a/javascript-modules-library/src/framework/contentPatches/operations.ts b/javascript-modules-library/src/framework/contentPatches/operations.ts new file mode 100644 index 00000000..cd3ffbc6 --- /dev/null +++ b/javascript-modules-library/src/framework/contentPatches/operations.ts @@ -0,0 +1,244 @@ +import type { Node } from "javax.jcr"; +import type { Locale } from "java.util"; +import type { JCRNodeWrapper, JCRValueWrapper } from "org.jahia.services.content"; +import type { + JavaContentPatchSupport, + ContentPatchJcr, + ContentPatchLogger, + ContentPatchOperationReport, + ContentPatchOperations, + ContentPatchPropertyValue, +} from "./types.js"; + +const EMPTY_REPORT = (): ContentPatchOperationReport => ({ + matched: 0, + updated: 0, + skipped: 0, + byWorkspace: {}, +}); + +/** Iterates a Java List through its size()/get() members (safe under GraalVM interop). */ +const listToArray = (list: { size(): number; get(index: number): unknown }): T[] => { + const result: T[] = []; + for (let i = 0; i < list.size(); i++) { + result.push(list.get(i) as T); + } + return result; +}; + +/** Builds the `patch` part of the content patch context. */ +export const createContentPatchOperations = ( + jcr: ContentPatchJcr, + support: JavaContentPatchSupport, + log: ContentPatchLogger, +): ContentPatchOperations => { + /** Fresh-install guard: a selection on a type that was never registered here is a graceful no-op. */ + const guarded = ( + nodeType: string, + operation: () => ContentPatchOperationReport, + ): ContentPatchOperationReport => { + if (!support.isNodeTypeRegistered(nodeType)) { + log.info( + `Node type ${nodeType} is not registered on this instance (fresh install?) — nothing to do`, + ); + return EMPTY_REPORT(); + } + return operation(); + }; + + /** The locales of the existing translation subnodes of a node. */ + const existingLocales = (node: JCRNodeWrapper): Locale[] => + listToArray(node.getExistingLocales() as never); + + /** The locales to write i18n values for: explicit option, else the resolved site's languages. */ + const targetLocales = (node: JCRNodeWrapper, locales: string[] | undefined): Locale[] => { + let siteLocales: Locale[]; + try { + siteLocales = listToArray(node.getResolveSite().getLanguagesAsLocales() as never); + } catch { + siteLocales = existingLocales(node); + } + return locales ? siteLocales.filter((l) => locales.includes(l.toString())) : siteLocales; + }; + + const removePropertyValues: ContentPatchOperations["removePropertyValues"] = (options) => + guarded(options.nodeType, () => + jcr.forEachNode(options, (node) => { + let touched = false; + if (node.hasProperty(options.property)) { + node.getProperty(options.property).remove(); + touched = true; + } + for (const locale of existingLocales(node)) { + const translation = node.getI18N(locale); + if (translation.hasProperty(options.property)) { + translation.getProperty(options.property).remove(); + touched = true; + } + } + return touched; + }), + ); + + const setPropertyValues: ContentPatchOperations["setPropertyValues"] = (options) => { + const onlyIfMissing = options.onlyIfMissing ?? true; + const resolveValue = ( + node: JCRNodeWrapper, + locale?: string, + ): ContentPatchPropertyValue | undefined => + typeof options.value === "function" ? options.value(node, locale) : options.value; + + return guarded(options.nodeType, () => + jcr.forEachNode(options, (node) => { + const definition = node.getApplicablePropertyDefinition(options.property); + if (!definition) { + return false; // the type has no such property — nothing to set + } + if (definition.isMultiple()) { + log.warn( + `Property ${options.property} is multi-valued, which setPropertyValues does not support — use jcr.forEachNode`, + ); + return false; + } + let touched = false; + if (definition.isInternationalized()) { + for (const locale of targetLocales(node, options.locales)) { + const translation = node.getOrCreateI18N(locale); + if (onlyIfMissing && translation.hasProperty(options.property)) continue; + const value = resolveValue(node, locale.toString()); + if (value === undefined) continue; + translation.setProperty(options.property, value as never); + touched = true; + } + } else { + if (onlyIfMissing && node.hasProperty(options.property)) return false; + const value = resolveValue(node); + if (value === undefined) return false; + node.setProperty(options.property, value as never); + touched = true; + } + return touched; + }), + ); + }; + + const convertPropertyValues: ContentPatchOperations["convertPropertyValues"] = (options) => { + /** Converts the property on one node (or translation node); returns whether it was rewritten. */ + const convertOn = (owner: JCRNodeWrapper | Node, node: JCRNodeWrapper): boolean => { + if (!owner.hasProperty(options.property)) return false; + const property = owner.getProperty(options.property); + if (property.isMultiple()) { + log.warn( + `Property ${options.property} on ${node.getPath()} is multi-valued, which convertPropertyValues does not support — use jcr.forEachNode`, + ); + return false; + } + const newValue = options.convert(property.getValue() as JCRValueWrapper, node); + if (newValue === undefined) return false; + // Remove first: the stored value still carries the previous data type, and JCR refuses to + // change the type of an existing property in place. + property.remove(); + owner.setProperty(options.property, newValue as never); + return true; + }; + + return guarded(options.nodeType, () => + jcr.forEachNode(options, (node) => { + let touched = convertOn(node, node); + for (const locale of existingLocales(node)) { + touched = convertOn(node.getI18N(locale), node) || touched; + } + return touched; + }), + ); + }; + + const removeNodeType: ContentPatchOperations["removeNodeType"] = (options) => + guarded(options.nodeType, () => { + const mode = options.ifContentExists ?? "fail"; + const report = jcr.forEachNode({ ...options, includeSubtypes: false }, (node) => { + if (mode === "fail") { + throw new Error( + `Cannot remove node type ${options.nodeType}: instances still exist (e.g. ${node.getPath()}). ` + + `Migrate or delete them first, or opt into deletion with ifContentExists: "delete".`, + ); + } + node.remove(); + }); + support.unregisterNodeType(options.nodeType); + log.info(`Node type ${options.nodeType} unregistered`); + return report; + }); + + const changeNodeType: ContentPatchOperations["changeNodeType"] = (options) => { + /** Renames a property on a node or translation node, preserving raw values (incl. multi-valued). */ + const renameOn = (owner: JCRNodeWrapper | Node, oldName: string, newName: string) => { + if (!owner.hasProperty(oldName)) return; + const property = owner.getProperty(oldName); + if (property.isMultiple()) { + const values = property.getValues(); + property.remove(); + owner.setProperty(newName, values as never); + } else { + const value = property.getValue(); + property.remove(); + owner.setProperty(newName, value as never); + } + }; + + return guarded(options.from, () => { + if (!support.isNodeTypeRegistered(options.to)) { + throw new Error( + `Cannot rebind ${options.from} to ${options.to}: the target type is not registered — ` + + `is it declared in the module's current definitions?`, + ); + } + const report = jcr.forEachNode( + { ...options, nodeType: options.from, includeSubtypes: false }, + (node) => { + // Jahia's node wrapper does not implement setPrimaryType (it throws + // UnsupportedRepositoryOperationException) — rebind on the underlying Jackrabbit node. + const realNode = node.getRealNode(); + // Jackrabbit validates EXISTING properties against the new type during setPrimaryType, + // so mapped properties must be read + removed before the retype and written back (under + // their new names, which only the new type defines) afterwards. + const carried: Array<{ newName: string; value: unknown; multiple: boolean }> = []; + for (const [oldName, newName] of Object.entries(options.mapProperties ?? {})) { + if (!realNode.hasProperty(oldName)) continue; + const property = realNode.getProperty(oldName); + const multiple = property.isMultiple(); + carried.push({ + newName, + value: multiple ? property.getValues() : property.getValue(), + multiple, + }); + property.remove(); + } + realNode.setPrimaryType(options.to); + for (const { newName, value } of carried) { + realNode.setProperty(newName, value as never); + } + // translation subnodes keep their own (residual-friendly) definitions — rename in place + for (const [oldName, newName] of Object.entries(options.mapProperties ?? {})) { + for (const locale of existingLocales(node)) { + renameOn(node.getI18N(locale), oldName, newName); + } + } + }, + ); + if (options.removeOldDefinition ?? true) { + support.unregisterNodeType(options.from); + log.info(`Node type ${options.from} unregistered`); + } + return report; + }); + }; + + return { + removePropertyValues, + setPropertyValues, + convertPropertyValues, + removeNodeType, + changeNodeType, + }; +}; diff --git a/javascript-modules-library/src/framework/contentPatches/registerContentPatch.ts b/javascript-modules-library/src/framework/contentPatches/registerContentPatch.ts new file mode 100644 index 00000000..e5a2add5 --- /dev/null +++ b/javascript-modules-library/src/framework/contentPatches/registerContentPatch.ts @@ -0,0 +1,99 @@ +import { createContentPatchJcr, ContentPatchSkipped } from "./jcr.js"; +import { createContentPatchOperations } from "./operations.js"; +import type { + JavaContentPatchSupport, + ContentPatchContext, + ContentPatchDeclaration, +} from "./types.js"; + +/** + * `registerContentPatch` calls are executed synchronously during module initialization. During this + * time, `bundleKey` is set to the symbolic name of the active bundle. + */ +declare const bundleKey: string; + +const RECOMMENDED_NAME_PATTERN = /^\d+\.\d+\.\d+-\d{2,}-[A-Za-z0-9._-]+$/; + +/** + * Registers a content patch: a run-once script executed on the processing server when a new version + * of this module starts, used to reconcile existing content with definition changes (the JavaScript + * equivalent of Jahia's Groovy `META-INF/patches` scripts). + * + * ```ts + * registerContentPatch({ name: "2.0.0-01-remove-legacy-color" }, ({ patch }) => { + * patch.removePropertyValues({ nodeType: "mymodule:banner", property: "color" }); + * }); + * ``` + * + * Execution is tracked in Jahia's module patch status store, keyed by `name`: whatever the outcome + * (`.installed`, `.skipped`, `.failed`), a recorded content patch never runs again. A module's + * content patches run in lexicographic order of their names; on failure the module still starts, + * but its remaining content patches are halted (they stay pending). + * + * Content patches run synchronously on the module start thread and must NOT be async — keep heavy + * work bounded through the built-in batching of the `patch.*` helpers and `jcr.forEachNode`. + * + * @param declaration The content patch declaration; `name` is its run-once identity and ordering + * key. + * @param run Performs the content patch. Returning normally records `.installed`; throwing records + * `.failed`; calling `context.skip(reason)` records `.skipped`. + */ +export const registerContentPatch = ( + { name, description }: ContentPatchDeclaration, + run: (context: ContentPatchContext) => void, +): void => { + if (!name || !/^\S+$/.test(name)) { + throw new Error( + `Invalid content patch name "${name}": the name is the content patch's run-once identity and must be a non-empty string without whitespace`, + ); + } + if (!RECOMMENDED_NAME_PATTERN.test(name)) { + console.debug( + `Content patch name "${name}" does not follow the recommended "--" convention (e.g. "2.0.0-01-remove-legacy-color"); note that content patches run in lexicographic name order`, + ); + } + const key = `${bundleKey}_content-patch_${name}`; + if (server.registry.get("content-patch", key)) { + throw new Error( + `Duplicate content patch name "${name}": another content patch with the same name is already registered in this module`, + ); + } + server.registry.add("content-patch", key, { + name, + description: description ?? "", + // Raw adapter invoked by the Java ContentPatchRegistrar with a ContentPatchSupport object. It returns + // the result string to record; throwing records `.failed`. Keep both shapes in sync. + execute: (support: JavaContentPatchSupport) => { + const log = support.getLogger(name); + const dryRun = support.isDryRun(); + const jcr = createContentPatchJcr(dryRun, log); + const context: ContentPatchContext = { + jcr, + patch: createContentPatchOperations(jcr, support, log), + log, + dryRun, + module: { name: support.getModuleName(), version: support.getModuleVersion() }, + skip: (reason) => { + throw new ContentPatchSkipped(reason); + }, + }; + if (description) log.info(description); + try { + const result = run(context) as unknown; + if (result && typeof (result as PromiseLike).then === "function") { + throw new Error( + `Content patch returned a promise: content patches must be synchronous (do not use an async run function)`, + ); + } + return ".installed"; + } catch (error) { + if (error instanceof ContentPatchSkipped) { + log.info(`Content patch skipped: ${error.reason}`); + return ".skipped"; + } + throw error; + } + }, + }); + console.debug(`Registered content patch ${name}`); +}; diff --git a/javascript-modules-library/src/framework/contentPatches/types.ts b/javascript-modules-library/src/framework/contentPatches/types.ts new file mode 100644 index 00000000..1d01ec1f --- /dev/null +++ b/javascript-modules-library/src/framework/contentPatches/types.ts @@ -0,0 +1,205 @@ +import type { + JCRNodeWrapper, + JCRSessionWrapper, + JCRValueWrapper, +} from "org.jahia.services.content"; + +/** Declaration of a content patch. */ +export interface ContentPatchDeclaration { + /** + * Run-once identity AND ordering key of the content patch (content patches of a module are + * executed in lexicographic order of their names). + * + * Recommended shape: `"--"`, e.g. `"2.0.0-01-remove-legacy-color"`. + * + * NEVER rename or reorder a content patch after it shipped in a release — the name is the key + * under which its execution is recorded; ship a new content patch instead. + */ + name: string; + /** Shown in logs and in the content patch status. */ + description?: string; +} + +/** + * Logger dedicated to one content patch (backed by SLF4J, named + * `org.jahia.modules.javascript.modules.engine.contentpatches..`). + */ +export interface ContentPatchLogger { + debug(message: string): void; + info(message: string): void; + warn(message: string): void; + error(message: string): void; +} + +/** Values accepted by the property-writing helpers. */ +export type ContentPatchPropertyValue = string | number | boolean | JCRNodeWrapper; + +/** Common selection options for bulk operations. */ +export interface NodeSelection { + /** Node type whose instances to process. */ + nodeType: string; + /** Whether instances of subtypes are processed too. @default true */ + includeSubtypes?: boolean; + /** Limit the selection to a subtree, e.g. `"/sites/acme"`. @default the whole workspace */ + scope?: string; + /** Optional JCR-SQL2 constraint appended to the generated query, e.g. `"[price] > 1000"`. */ + where?: string; + /** Workspaces to process. @default ["default", "live"] */ + workspaces?: ("default" | "live")[]; + /** Number of nodes per save/refresh cycle. @default 100 */ + batchSize?: number; +} + +/** Raw-query selection, for `jcr.forEachNode`. */ +export interface QuerySelection { + /** Full JCR-SQL2 query selecting the nodes to process. */ + query: string; + /** Workspaces to process. @default ["default", "live"] */ + workspaces?: ("default" | "live")[]; + /** Number of nodes per save/refresh cycle. @default 100 */ + batchSize?: number; +} + +/** Outcome of a bulk operation. */ +export interface ContentPatchOperationReport { + /** Nodes matched by the selection, across all processed workspaces. */ + matched: number; + /** Nodes actually modified. */ + updated: number; + /** Nodes matched but left untouched (already up to date, vanished mid-run, …). */ + skipped: number; + /** Same counters, per workspace. */ + byWorkspace: Record; +} + +/** + * High-level, guard-railed operations. They all iterate `default` and `live`, commit in batches, + * handle i18n properties on their translation subnodes, no-op gracefully when the node type was + * never registered on this instance (fresh installs), and honor dry-run mode. + */ +export interface ContentPatchOperations { + /** Removes leftover values of a property (i18n-aware) on all instances of a node type. */ + removePropertyValues(options: NodeSelection & { property: string }): ContentPatchOperationReport; + + /** Sets a property value on existing content, e.g. to backfill a newly added property. */ + setPropertyValues( + options: NodeSelection & { + property: string; + /** + * Constant value, or per-node function returning the value to set (return `undefined` to skip + * the node). For i18n properties the function is called once per locale. + */ + value: + | ContentPatchPropertyValue + | ((node: JCRNodeWrapper, locale?: string) => ContentPatchPropertyValue | undefined); + /** Never overwrite an existing value. @default true */ + onlyIfMissing?: boolean; + /** For i18n properties: language codes to process. @default the site languages */ + locales?: string[]; + }, + ): ContentPatchOperationReport; + + /** Rewrites the values of a property after its data type changed in the definitions. */ + convertPropertyValues( + options: NodeSelection & { + property: string; + /** + * Converts one stored value (which may still carry the previous data type) to the new value. + * Return `undefined` to leave that value untouched. + */ + convert: ( + value: JCRValueWrapper, + node: JCRNodeWrapper, + ) => ContentPatchPropertyValue | undefined; + }, + ): ContentPatchOperationReport; + + /** + * Removes a node type OWNED BY THIS MODULE from the registry, optionally purging its instances. + * With the default `ifContentExists: "fail"`, the content patch fails (and content is left + * untouched) if instances still exist — destroying content is opt-in. + */ + removeNodeType(options: { + nodeType: string; + /** @default "fail" */ + ifContentExists?: "fail" | "delete"; + /** @default ["default", "live"] */ + workspaces?: ("default" | "live")[]; + /** @default 100 */ + batchSize?: number; + }): ContentPatchOperationReport; + + /** + * Rebinds all instances of the `from` node type to the `to` node type (which must exist in the + * module's current definitions), optionally renaming properties, then removes the old + * definition. + */ + changeNodeType( + options: Omit & { + from: string; + to: string; + /** Property renames to apply on each rebound node (i18n-aware), as `{ oldName: newName }`. */ + mapProperties?: Record; + /** Unregister the `from` definition once all instances are rebound. @default true */ + removeOldDefinition?: boolean; + }, + ): ContentPatchOperationReport; +} + +/** Lower-level JCR access for content patches. */ +export interface ContentPatchJcr { + /** + * Opens a system (root) session on the given workspace and executes the callback with it. With + * `locale: null` (the default), translation nodes are visible as plain subnodes, which is usually + * what content patches want. + * + * NOTE: unlike the `patch.*` helpers, code in this callback is NOT dry-run aware — check + * `context.dryRun` yourself before saving if you want to support dry runs. + */ + withSystemSession( + options: { workspace?: "default" | "live"; locale?: string | null }, + callback: (session: JCRSessionWrapper) => T, + ): T; + + /** + * The batching engine behind the `patch.*` helpers: iterates the selected nodes in batches, + * committing (`session.save()`) after each batch — or discarding changes in dry-run mode. The + * callback may return `false` to count the node as skipped instead of updated. + */ + forEachNode( + options: NodeSelection | QuerySelection, + callback: (node: JCRNodeWrapper) => boolean | void, + ): ContentPatchOperationReport; +} + +/** Context passed to a content patch's run function. */ +export interface ContentPatchContext { + /** High-level, guard-railed operations. */ + patch: ContentPatchOperations; + /** Lower-level JCR access: system sessions and the shared batching iterator. */ + jcr: ContentPatchJcr; + /** Logger dedicated to this content patch. */ + log: ContentPatchLogger; + /** True in dry-run mode: helpers report what they would do instead of saving. */ + dryRun: boolean; + /** Aborts the content patch now and records it as `.skipped` (terminal — it will not run again). */ + skip(reason: string): never; + /** The module owning this content patch. */ + module: { name: string; version: string }; +} + +/** + * Shape of the Java support object handed by the engine's ContentPatchRegistrar to the registered + * `execute` adapter. Keep in sync with + * `org.jahia.modules.javascript.modules.engine.registrars.contentpatches.ContentPatchSupport`. + * + * @internal + */ +export interface JavaContentPatchSupport { + getLogger(patchName: string): ContentPatchLogger; + isDryRun(): boolean; + getModuleName(): string; + getModuleVersion(): string; + isNodeTypeRegistered(name: string): boolean; + unregisterNodeType(name: string): void; +} diff --git a/javascript-modules-library/src/index.ts b/javascript-modules-library/src/index.ts index 276252f4..810600c5 100644 --- a/javascript-modules-library/src/index.ts +++ b/javascript-modules-library/src/index.ts @@ -12,6 +12,18 @@ export { Area } from "./components/Area.js"; // Declaration and registration export { jahiaComponent } from "./framework/jahiaComponent.js"; +export { registerContentPatch } from "./framework/contentPatches/registerContentPatch.js"; +export type { + ContentPatchContext, + ContentPatchDeclaration, + ContentPatchJcr, + ContentPatchLogger, + ContentPatchOperationReport, + ContentPatchOperations, + ContentPatchPropertyValue, + NodeSelection, + QuerySelection, +} from "./framework/contentPatches/types.js"; // Hooks export { useGQLQuery } from "./hooks/useGQLQuery.js"; diff --git a/tests/cypress/e2e/engine/contentPatchTest.cy.ts b/tests/cypress/e2e/engine/contentPatchTest.cy.ts new file mode 100644 index 00000000..08656b4b --- /dev/null +++ b/tests/cypress/e2e/engine/contentPatchTest.cy.ts @@ -0,0 +1,128 @@ +/** + * Verifies the JS content patches of the test module (declared in + * jahia-test-module/src/react/server/extensions/contentPatches.ts): they ran once at module start, + * in name order, with the expected terminal statuses in Jahia's module patch status store, and the + * fixture content was transformed accordingly. + */ + +const MODULE_NAME = "javascript-modules-engine-test-module"; +const STATUS_PATH_PREFIX = "/javascript/content-patches/"; +const FIXTURES_PATH = "/sites/systemsite/contents/content-patch-tests"; + +const NODE_QUERY = ` + query migratedNode($path: String!) { + jcr { + nodeByPath(path: $path) { + primaryNodeType { + name + } + properties { + name + value + } + } + } + } +`; + +const STATUS_QUERY = ` + query contentPatchStatuses { + jcr { + nodeByPath(path: "/module-management") { + property(name: "j:bundlesScripts") { + value + } + } + } + } +`; + +interface NodeResponse { + data?: { + jcr?: { + nodeByPath?: { + primaryNodeType: { name: string }; + properties: Array<{ name: string; value: string }>; + }; + }; + }; + errors?: Array<{ message: string }>; +} + +const getNode = (path: string) => + cy.apollo({ query: NODE_QUERY, variables: { path }, errorPolicy: "all" }); + +const propertiesOf = (response: NodeResponse): Record => + Object.fromEntries( + (response.data?.jcr?.nodeByPath?.properties ?? []).map(({ name, value }) => [name, value]), + ); + +describe("JS content patches", () => { + beforeEach("Login", () => { + cy.login(); + }); + afterEach("Logout", () => { + cy.logout(); + }); + + it("records terminal statuses in the module patch status store", () => { + cy.apollo({ query: STATUS_QUERY }).then((response) => { + const raw = response.data?.jcr?.nodeByPath?.property?.value; + expect(raw, "j:bundlesScripts property").to.be.a("string"); + const statuses = (JSON.parse(raw)[MODULE_NAME] ?? {}) as Record; + + for (const installed of [ + "1.0.0-01-create-fixture-content", + "1.0.0-02-remove-legacy-color", + "1.0.0-03-backfill-theme", + "1.0.0-04-convert-counter", + "1.0.0-05-rename-legacy-type", + "1.0.0-06-remove-doomed-type", + ]) { + expect(statuses[STATUS_PATH_PREFIX + installed], installed).to.equal(".installed"); + } + expect(statuses[STATUS_PATH_PREFIX + "1.0.0-07-skip-me"], "07 skips").to.equal(".skipped"); + expect(statuses[STATUS_PATH_PREFIX + "1.0.0-08-fail-on-purpose"], "08 fails").to.equal( + ".failed", + ); + // 08 failed, so 09 was halted: no status, and it stays pending + expect( + statuses[STATUS_PATH_PREFIX + "1.0.0-09-after-failure-never-runs"], + "09 is halted by 08", + ).to.equal(undefined); + }); + }); + + it("removes property values and backfills new ones (U1, U2)", () => { + getNode(`${FIXTURES_PATH}/content1`).then((response: NodeResponse) => { + const properties = propertiesOf(response); + expect(properties.legacyColor, "legacyColor cleaned by 02").to.equal(undefined); + // 03 backfilled "light"; if 09 had run despite the halt, it would be "NEVER" + expect(properties.theme, "theme backfilled by 03").to.equal("light"); + }); + }); + + it("converts property values (U3)", () => { + getNode(`${FIXTURES_PATH}/content1`).then((response: NodeResponse) => { + // the numeric representation may serialize as "42" or "42.0" depending on the stored type + expect(propertiesOf(response).counter, "counter converted by 04").to.match(/^42(\.0)?$/); + }); + }); + + it("rebinds content to a renamed node type (U5)", () => { + getNode(`${FIXTURES_PATH}/legacy1`).then((response: NodeResponse) => { + expect(response.data?.jcr?.nodeByPath?.primaryNodeType.name, "rebound by 05").to.equal( + "javascriptExample:patchTestNew", + ); + const properties = propertiesOf(response); + expect(properties.newTitle, "value moved to newTitle").to.equal("hello"); + expect(properties.oldTitle, "oldTitle removed").to.equal(undefined); + }); + }); + + it("purges instances of a removed node type (U4)", () => { + getNode(`${FIXTURES_PATH}/doomed1`).then((response: NodeResponse) => { + expect(response.data?.jcr?.nodeByPath ?? null, "doomed1 deleted by 06").to.equal(null); + }); + }); +}); From d154c2d398bf49df64fe273b4ed645c723a23200 Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Mon, 27 Jul 2026 09:43:43 +0200 Subject: [PATCH 2/6] fix(tests): load content patch queries from fixture files 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. --- .../cypress/e2e/engine/contentPatchTest.cy.ts | 36 ++++--------------- .../fixtures/graphql/contentPatchNode.graphql | 13 +++++++ .../graphql/contentPatchStatuses.graphql | 9 +++++ 3 files changed, 28 insertions(+), 30 deletions(-) create mode 100644 tests/cypress/fixtures/graphql/contentPatchNode.graphql create mode 100644 tests/cypress/fixtures/graphql/contentPatchStatuses.graphql diff --git a/tests/cypress/e2e/engine/contentPatchTest.cy.ts b/tests/cypress/e2e/engine/contentPatchTest.cy.ts index 08656b4b..934c0a1f 100644 --- a/tests/cypress/e2e/engine/contentPatchTest.cy.ts +++ b/tests/cypress/e2e/engine/contentPatchTest.cy.ts @@ -9,34 +9,6 @@ const MODULE_NAME = "javascript-modules-engine-test-module"; const STATUS_PATH_PREFIX = "/javascript/content-patches/"; const FIXTURES_PATH = "/sites/systemsite/contents/content-patch-tests"; -const NODE_QUERY = ` - query migratedNode($path: String!) { - jcr { - nodeByPath(path: $path) { - primaryNodeType { - name - } - properties { - name - value - } - } - } - } -`; - -const STATUS_QUERY = ` - query contentPatchStatuses { - jcr { - nodeByPath(path: "/module-management") { - property(name: "j:bundlesScripts") { - value - } - } - } - } -`; - interface NodeResponse { data?: { jcr?: { @@ -50,7 +22,11 @@ interface NodeResponse { } const getNode = (path: string) => - cy.apollo({ query: NODE_QUERY, variables: { path }, errorPolicy: "all" }); + cy.apollo({ + queryFile: "graphql/contentPatchNode.graphql", + variables: { path }, + errorPolicy: "all", + }); const propertiesOf = (response: NodeResponse): Record => Object.fromEntries( @@ -66,7 +42,7 @@ describe("JS content patches", () => { }); it("records terminal statuses in the module patch status store", () => { - cy.apollo({ query: STATUS_QUERY }).then((response) => { + cy.apollo({ queryFile: "graphql/contentPatchStatuses.graphql" }).then((response) => { const raw = response.data?.jcr?.nodeByPath?.property?.value; expect(raw, "j:bundlesScripts property").to.be.a("string"); const statuses = (JSON.parse(raw)[MODULE_NAME] ?? {}) as Record; diff --git a/tests/cypress/fixtures/graphql/contentPatchNode.graphql b/tests/cypress/fixtures/graphql/contentPatchNode.graphql new file mode 100644 index 00000000..450e1b3a --- /dev/null +++ b/tests/cypress/fixtures/graphql/contentPatchNode.graphql @@ -0,0 +1,13 @@ +query migratedNode($path: String!) { + jcr { + nodeByPath(path: $path) { + primaryNodeType { + name + } + properties { + name + value + } + } + } +} diff --git a/tests/cypress/fixtures/graphql/contentPatchStatuses.graphql b/tests/cypress/fixtures/graphql/contentPatchStatuses.graphql new file mode 100644 index 00000000..85e673f6 --- /dev/null +++ b/tests/cypress/fixtures/graphql/contentPatchStatuses.graphql @@ -0,0 +1,9 @@ +query contentPatchStatuses { + jcr { + nodeByPath(path: "/module-management") { + property(name: "j:bundlesScripts") { + value + } + } + } +} From 673986dc4a02f36814f3a3e5f0ffc5c3213a4ea2 Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Fri, 14 Aug 2026 14:04:51 +0200 Subject: [PATCH 3/6] fix(engine): record .skipped when skip() is called inside a session callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../react/server/extensions/contentPatches.ts | 10 +++++++-- .../modules/engine/js/server/JcrHelper.java | 5 +++++ .../contentPatches/registerContentPatch.ts | 21 ++++++++++++++----- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/jahia-test-module/src/react/server/extensions/contentPatches.ts b/jahia-test-module/src/react/server/extensions/contentPatches.ts index 47585c8f..6f754286 100644 --- a/jahia-test-module/src/react/server/extensions/contentPatches.ts +++ b/jahia-test-module/src/react/server/extensions/contentPatches.ts @@ -125,8 +125,14 @@ 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"); + ({ jcr, skip }) => { + // skip() from inside a session callback: the throw crosses a host boundary, which used to + // destroy its identity and record .failed — this is the guide's "nothing to fix here" pattern + jcr.withSystemSession({}, (session) => { + if (!session.nodeExists("/sites/systemsite/contents/does-not-exist")) { + skip("nothing to do on this instance"); + } + }); }, ); diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/JcrHelper.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/JcrHelper.java index b2eeb8c7..f6d5bc39 100644 --- a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/JcrHelper.java +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/JcrHelper.java @@ -65,6 +65,11 @@ public Object doExecuteAsSystem(JCRCallback callback, String language, S Locale locale = language != null ? LanguageCodeConverters.languageCodeToLocale(language) : null; try { return JCRTemplate.getInstance().doExecuteWithSystemSessionAsUser(null, workspace, locale, callback); + } catch (RuntimeException e) { + // rethrow unchanged: a guest (JS) exception crossing this boundary arrives as an unchecked + // PolyglotException, and GraalVM only restores its guest identity — needed for skip() + // detection in content patches — when the host rethrows it as-is + throw e; } catch (Exception e) { // include the class name: many JCR exceptions (e.g. UnsupportedRepositoryOperationException) // carry a null message, and the polyglot boundary hides the Java cause chain from JS diff --git a/javascript-modules-library/src/framework/contentPatches/registerContentPatch.ts b/javascript-modules-library/src/framework/contentPatches/registerContentPatch.ts index e5a2add5..d08b3d63 100644 --- a/javascript-modules-library/src/framework/contentPatches/registerContentPatch.ts +++ b/javascript-modules-library/src/framework/contentPatches/registerContentPatch.ts @@ -27,8 +27,11 @@ const RECOMMENDED_NAME_PATTERN = /^\d+\.\d+\.\d+-\d{2,}-[A-Za-z0-9._-]+$/; * * Execution is tracked in Jahia's module patch status store, keyed by `name`: whatever the outcome * (`.installed`, `.skipped`, `.failed`), a recorded content patch never runs again. A module's - * content patches run in lexicographic order of their names; on failure the module still starts, - * but its remaining content patches are halted (they stay pending). + * content patches run in lexicographic order of their names — note that unpadded numbers break that + * order once they reach double digits (`"10.0.0-…"` sorts before `"2.0.0-…"`); this only matters + * when several versions' patches are pending at once, but padding the sequence number (`-01-`, + * `-02-`, …) is always safe. On failure the module still starts, but its remaining content patches + * are halted (they stay pending). * * Content patches run synchronously on the module start thread and must NOT be async — keep heavy * work bounded through the built-in batching of the `patch.*` helpers and `jcr.forEachNode`. @@ -67,6 +70,11 @@ export const registerContentPatch = ( const log = support.getLogger(name); const dryRun = support.isDryRun(); const jcr = createContentPatchJcr(dryRun, log); + // Skip detection is flag-based, not identity-based: when skip() is called inside a callback + // that crosses a host boundary (jcr.withSystemSession, jcr.forEachNode, the patch.* helpers), + // the thrown ContentPatchSkipped may come back wrapped in a host exception, so the caught + // error's type cannot be trusted. + let skipReason: string | undefined; const context: ContentPatchContext = { jcr, patch: createContentPatchOperations(jcr, support, log), @@ -74,6 +82,7 @@ export const registerContentPatch = ( dryRun, module: { name: support.getModuleName(), version: support.getModuleVersion() }, skip: (reason) => { + skipReason = reason; throw new ContentPatchSkipped(reason); }, }; @@ -82,13 +91,15 @@ export const registerContentPatch = ( const result = run(context) as unknown; if (result && typeof (result as PromiseLike).then === "function") { throw new Error( - `Content patch returned a promise: content patches must be synchronous (do not use an async run function)`, + `Content patch ${name} returned a promise: content patches must be synchronous (do not use an async run function)`, ); } return ".installed"; } catch (error) { - if (error instanceof ContentPatchSkipped) { - log.info(`Content patch skipped: ${error.reason}`); + if (skipReason !== undefined || error instanceof ContentPatchSkipped) { + log.info( + `Content patch skipped: ${skipReason ?? (error as ContentPatchSkipped).reason}`, + ); return ".skipped"; } throw error; From dfb4b8b20d33e7bac22025d2215c52c82e0571c1 Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Fri, 14 Aug 2026 14:04:51 +0200 Subject: [PATCH 4/6] fix(engine): refuse foreign node types before mutating content, not after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../contentpatches/ContentPatchSupport.java | 35 +++++++++++++------ .../framework/contentPatches/operations.ts | 21 +++++++++-- .../src/framework/contentPatches/types.ts | 1 + 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchSupport.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchSupport.java index be3a7361..2398d2f2 100644 --- a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchSupport.java +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchSupport.java @@ -68,31 +68,46 @@ public boolean isNodeTypeRegistered(String name) { } /** - * Unregisters a node type OWNED BY THIS MODULE from the node type registry. Types registered by - * another module (different {@code systemId}) are refused — cross-module definition surgery is - * out of scope for JS content patches. + * Asserts a node type is either absent or OWNED BY THIS MODULE (same {@code systemId} as the + * bundle). Definition operations must call this BEFORE mutating any content, so the refusal + * fires before anything is deleted or retyped. * * @throws IllegalArgumentException if the type belongs to another module - * @throws IllegalStateException if the registry refuses the removal (e.g. remaining usages) */ - public void unregisterNodeType(String name) { + public void assertOwnedNodeType(String name) { 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; + return; // fresh install: nothing registered, nothing to refuse } ExtendedNodeType type; try { type = registry.getNodeType(name); } catch (NoSuchNodeTypeException e) { - return; // raced away, nothing to do + return; // raced away, nothing to check } 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"); + + "') — content patches may only alter their own definitions"); + } + } + + /** + * Unregisters a node type OWNED BY THIS MODULE from the node type registry. Types registered by + * another module (different {@code systemId}) are refused — cross-module definition surgery is + * out of scope for JS content patches. + * + * @throws IllegalArgumentException if the type belongs to another module + * @throws IllegalStateException if the registry refuses the removal (e.g. remaining usages) + */ + public void unregisterNodeType(String name) { + 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; } + assertOwnedNodeType(name); if (dryRun) { getLogger(bundle.getSymbolicName()).info("[dry-run] would unregister node type {}", name); return; diff --git a/javascript-modules-library/src/framework/contentPatches/operations.ts b/javascript-modules-library/src/framework/contentPatches/operations.ts index cd3ffbc6..c54b8a5b 100644 --- a/javascript-modules-library/src/framework/contentPatches/operations.ts +++ b/javascript-modules-library/src/framework/contentPatches/operations.ts @@ -103,11 +103,18 @@ export const createContentPatchOperations = ( let touched = false; if (definition.isInternationalized()) { for (const locale of targetLocales(node, options.locales)) { - const translation = node.getOrCreateI18N(locale); - if (onlyIfMissing && translation.hasProperty(options.property)) continue; + if ( + onlyIfMissing && + node.hasI18N(locale) && + node.getI18N(locale).hasProperty(options.property) + ) { + continue; + } const value = resolveValue(node, locale.toString()); + // resolve the value before creating the translation subnode: a callback returning + // undefined must not leave behind an empty (but persisted) translation node if (value === undefined) continue; - translation.setProperty(options.property, value as never); + node.getOrCreateI18N(locale).setProperty(options.property, value as never); touched = true; } } else { @@ -155,6 +162,9 @@ export const createContentPatchOperations = ( const removeNodeType: ContentPatchOperations["removeNodeType"] = (options) => guarded(options.nodeType, () => { + // refuse foreign types BEFORE deleting anything — the check inside unregisterNodeType would + // only fire after the batched content deletions have already been committed + support.assertOwnedNodeType(options.nodeType); const mode = options.ifContentExists ?? "fail"; const report = jcr.forEachNode({ ...options, includeSubtypes: false }, (node) => { if (mode === "fail") { @@ -187,12 +197,17 @@ export const createContentPatchOperations = ( }; return guarded(options.from, () => { + // refuse foreign types BEFORE retyping anything (also covers removeOldDefinition: false, + // where unregisterNodeType — and its own check — never runs) + support.assertOwnedNodeType(options.from); if (!support.isNodeTypeRegistered(options.to)) { throw new Error( `Cannot rebind ${options.from} to ${options.to}: the target type is not registered — ` + `is it declared in the module's current definitions?`, ); } + // per the documented contract, the target must also be a type this module owns + support.assertOwnedNodeType(options.to); const report = jcr.forEachNode( { ...options, nodeType: options.from, includeSubtypes: false }, (node) => { diff --git a/javascript-modules-library/src/framework/contentPatches/types.ts b/javascript-modules-library/src/framework/contentPatches/types.ts index 1d01ec1f..61ee4126 100644 --- a/javascript-modules-library/src/framework/contentPatches/types.ts +++ b/javascript-modules-library/src/framework/contentPatches/types.ts @@ -201,5 +201,6 @@ export interface JavaContentPatchSupport { getModuleName(): string; getModuleVersion(): string; isNodeTypeRegistered(name: string): boolean; + assertOwnedNodeType(name: string): void; unregisterNodeType(name: string): void; } From 69f1ad8d65569e9d9600dad12624d92fa05d3ec8 Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Fri, 14 Aug 2026 14:04:51 +0200 Subject: [PATCH 5/6] fix(engine): give dryRun an activation path and surface status-store failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../contentpatches/ContentPatchRegistrar.java | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchRegistrar.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchRegistrar.java index 2d1c968d..79725f3c 100644 --- a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchRegistrar.java +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/contentpatches/ContentPatchRegistrar.java @@ -57,6 +57,10 @@ * {@code org.jahia.modules.javascript.modules.engine.contentpatches}, default {@code true}) can be set * to {@code false} on development/staging servers: pending patches are then only logged, and * execution is deferred to an explicit trigger. + * + *

The {@code dryRun} configuration property (same PID, default {@code false}) executes pending + * patches in dry-run mode: the {@code patch.*} helpers and sessions log what they would change + * without persisting, and no result is recorded in the status store — the patches stay pending. */ @Component(service = Registrar.class, immediate = true, configurationPid = ContentPatchRegistrar.CONFIG_PID, configurationPolicy = ConfigurationPolicy.OPTIONAL) @@ -76,6 +80,7 @@ public class ContentPatchRegistrar implements Registrar { protected GraalVMEngine graalVMEngine; private boolean autoRun = true; + private boolean dryRun = false; @Reference(cardinality = ReferenceCardinality.MANDATORY) public void setGraalVMEngine(GraalVMEngine graalVMEngine) { @@ -87,6 +92,8 @@ public void setGraalVMEngine(GraalVMEngine graalVMEngine) { public void activate(Map props) { Object value = props.get("autoRun"); this.autoRun = value == null || Boolean.parseBoolean(value.toString()); + Object dryRunValue = props.get("dryRun"); + this.dryRun = dryRunValue != null && Boolean.parseBoolean(dryRunValue.toString()); } @Override @@ -141,10 +148,24 @@ public void register(Bundle bundle) { String name = String.valueOf(entry.get("name")); long startTime = System.currentTimeMillis(); String result = executeContentPatch(bundle, String.valueOf(entry.get("key")), name); - status.put(STATUS_PATH_PREFIX + name, result); - storeStatus(symbolicName, status); - logger.info("ContentPatch {} of module {} finished with result {} in {}ms", name, symbolicName, - result, System.currentTimeMillis() - startTime); + if (dryRun) { + logger.info("[dry-run] ContentPatch {} of module {} would record {} ({}ms) — " + + "nothing persisted, the patch stays pending", name, symbolicName, + result, System.currentTimeMillis() - startTime); + } else { + status.put(STATUS_PATH_PREFIX + name, result); + try { + storeStatus(symbolicName, status); + } catch (RepositoryException e) { + logger.error("ContentPatch {} of module {} executed with result {} but the status store " + + "could not be updated: the run-once guarantee is broken and it WILL run again " + + "on the next module start. Halting the module's remaining patches.", + name, symbolicName, result, e); + return; + } + logger.info("ContentPatch {} of module {} finished with result {} in {}ms", name, symbolicName, + result, System.currentTimeMillis() - startTime); + } if (RESULT_FAILED.equals(result)) { logger.error("ContentPatch {} of module {} failed — halting the module's remaining patches " + "(they stay pending). The module keeps starting.", name, symbolicName); @@ -217,6 +238,6 @@ protected void storeStatus(String symbolicName, JSONObject status) throws Reposi } protected ContentPatchSupport newContentPatchSupport(Bundle bundle) { - return new ContentPatchSupport(bundle, false); + return new ContentPatchSupport(bundle, dryRun); } } From 55dea1327a62d52830aff03ae83cacef15eadfa6 Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Fri, 14 Aug 2026 14:04:52 +0200 Subject: [PATCH 6/6] docs: align ADR-0006, the guide and the pom with what the branch ships - 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+ --- docs/2-guides/6-content-patches/README.md | 5 +++-- docs/adr/0006-javascript-content-patches.md | 8 ++++---- javascript-modules-engine-java/pom.xml | 6 ------ 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/docs/2-guides/6-content-patches/README.md b/docs/2-guides/6-content-patches/README.md index a73c8c5e..6b0b9c38 100644 --- a/docs/2-guides/6-content-patches/README.md +++ b/docs/2-guides/6-content-patches/README.md @@ -13,7 +13,7 @@ Typical uses: removing leftover values of a dropped property, backfilling a defa ## 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/`: +Call `registerContentPatch` at the top level of a server file (it registers the content patch as a side effect at module startup, like the other `register*` helpers). Convention: one content patch per file under `src/content-patches/`: ```ts // src/content-patches/2.0.0-01-remove-legacy-color.server.ts @@ -110,5 +110,6 @@ All three outcomes are terminal. Batches already committed before a failure stay ## 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. +- `dryRun` (same PID, default `false`): set to `true` to rehearse pending content patches — the `patch.*` helpers and sessions log what they would change without persisting, no result is recorded, and the patches stay pending. - 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. +- 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 and resets is planned. diff --git a/docs/adr/0006-javascript-content-patches.md b/docs/adr/0006-javascript-content-patches.md index 93b18fc0..962c8d0d 100644 --- a/docs/adr/0006-javascript-content-patches.md +++ b/docs/adr/0006-javascript-content-patches.md @@ -7,7 +7,7 @@ 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). +Full analysis, verified core facts and the API-first contract live in the working documents `CONTENT-PATCHES-PLAN.md` and `CONTENT-PATCHES-DEMO.md` (repository root). ## Decision Drivers @@ -19,7 +19,7 @@ Full analysis, verified core facts and the API-first contract live in the workin ## 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.** +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. @@ -27,7 +27,7 @@ Full analysis, verified core facts and the API-first contract live in the workin 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). +- `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/` 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. @@ -40,4 +40,4 @@ Option 2 was rejected because `Patcher`'s executor list is a hardcoded whole-lis - 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. +- Follow-up (not in this ADR): CLI/GraphQL tooling (`status`, dry-run-by-default `run`, `reset`, scaffolding) specified in `CONTENT-PATCHES-PLAN.md` §6/§12. diff --git a/javascript-modules-engine-java/pom.xml b/javascript-modules-engine-java/pom.xml index 287a0bcb..8ea5e035 100644 --- a/javascript-modules-engine-java/pom.xml +++ b/javascript-modules-engine-java/pom.xml @@ -221,12 +221,6 @@ 4.11.0 test - - - org.graalvm.js - js - test -