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); + }); + }); +});