|
| 1 | +--- |
| 2 | +page: |
| 3 | + $path: /sites/academy/home/documentation/jahia/8_2/developer/javascript-module-development/migrations |
| 4 | + jcr:title: Writing Migrations |
| 5 | + j:templateName: documentation |
| 6 | +content: |
| 7 | + $subpath: document-area/content |
| 8 | +--- |
| 9 | + |
| 10 | +Migrations 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. |
| 11 | + |
| 12 | +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. |
| 13 | + |
| 14 | +## Declaring a migration |
| 15 | + |
| 16 | +Call `registerMigration` at the top level of a server file (it registers the migration as a side effect at module startup, like `registerAction`). Convention: one migration per file under `src/migrations/`: |
| 17 | + |
| 18 | +```ts |
| 19 | +// src/migrations/2.0.0-01-remove-legacy-color.server.ts |
| 20 | +import { registerMigration } from "@jahia/javascript-modules-library"; |
| 21 | + |
| 22 | +registerMigration( |
| 23 | + { |
| 24 | + name: "2.0.0-01-remove-legacy-color", |
| 25 | + description: "color was dropped from mymodule:banner in 2.0.0 — clean leftover values", |
| 26 | + }, |
| 27 | + ({ migrate }) => { |
| 28 | + migrate.removePropertyValues({ nodeType: "mymodule:banner", property: "color" }); |
| 29 | + }, |
| 30 | +); |
| 31 | +``` |
| 32 | + |
| 33 | +The `name` is the migration's **run-once identity and ordering key**: |
| 34 | + |
| 35 | +- A module's migrations run in lexicographic order of their names. Use the `"<moduleVersion>-<NN>-<slug>"` convention to keep them sorted. |
| 36 | +- 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 migration **never runs again** — never rename or reorder a released migration; ship a new one instead. |
| 37 | + |
| 38 | +Migrations 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. |
| 39 | + |
| 40 | +## The `migrate.*` helpers |
| 41 | + |
| 42 | +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). |
| 43 | + |
| 44 | +```ts |
| 45 | +// U1 — remove a property + clean its values |
| 46 | +migrate.removePropertyValues({ nodeType: "mymodule:banner", property: "color" }); |
| 47 | + |
| 48 | +// U2 — add a property + backfill existing content |
| 49 | +migrate.setPropertyValues({ |
| 50 | + nodeType: "mymodule:banner", |
| 51 | + property: "theme", |
| 52 | + onlyIfMissing: true, // default — never clobbers an existing value |
| 53 | + value: (node) => (node.getProperty("price").getDouble() > 1000 ? "premium" : "light"), |
| 54 | +}); |
| 55 | + |
| 56 | +// U3 — change a property's data type + convert values |
| 57 | +migrate.convertPropertyValues({ |
| 58 | + nodeType: "mymodule:banner", |
| 59 | + property: "priority", |
| 60 | + convert: (value) => Number.parseInt(value.getString(), 10), // undefined = leave untouched |
| 61 | +}); |
| 62 | + |
| 63 | +// U4 — delete a definition (owned by this module) |
| 64 | +migrate.removeNodeType({ |
| 65 | + nodeType: "mymodule:legacyBanner", |
| 66 | + ifContentExists: "delete", // default is "fail" — destroying content is opt-in |
| 67 | +}); |
| 68 | + |
| 69 | +// U5 — rename a definition + rebind existing items |
| 70 | +migrate.changeNodeType({ |
| 71 | + from: "mymodule:oldBanner", |
| 72 | + to: "mymodule:banner", // must exist in the module's current definitions |
| 73 | + mapProperties: { legacyTitle: "title" }, |
| 74 | +}); |
| 75 | +``` |
| 76 | + |
| 77 | +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. |
| 78 | + |
| 79 | +## The imperative escape hatch |
| 80 | + |
| 81 | +For everything else (ACL fixes, node moves, one-off repairs), open a system session or use the batching engine directly: |
| 82 | + |
| 83 | +```ts |
| 84 | +registerMigration({ name: "2.0.0-02-fix-root-title" }, ({ jcr, log, skip }) => { |
| 85 | + jcr.withSystemSession({ workspace: "default" }, (session) => { |
| 86 | + const path = "/sites/mysite/contents/catalog-root"; |
| 87 | + if (!session.nodeExists(path)) skip("nothing to fix on this instance"); |
| 88 | + session.getNode(path).getRealNode().setProperty("jcr:title", null); |
| 89 | + session.save(); |
| 90 | + log.info(`Cleaned stray jcr:title on ${path}`); |
| 91 | + }); |
| 92 | + |
| 93 | + jcr.forEachNode( |
| 94 | + { query: "SELECT * FROM [mymodule:banner]", workspaces: ["default", "live"] }, |
| 95 | + (node) => node.setProperty("migrated", true), |
| 96 | + ); |
| 97 | +}); |
| 98 | +``` |
| 99 | + |
| 100 | +With `locale: null` (the default), system sessions see translation subnodes as plain nodes — usually what migrations want. |
| 101 | + |
| 102 | +## Outcomes and failure semantics |
| 103 | + |
| 104 | +- Returning normally records `.installed`. |
| 105 | +- Calling `context.skip(reason)` records `.skipped`. |
| 106 | +- Throwing records `.failed`, and the module's **remaining migrations are halted** (they stay pending). The module itself still starts — migration failures never break startup. |
| 107 | + |
| 108 | +All three outcomes are terminal. Batches already committed before a failure stay committed (JCR has no cross-save transactions), so write migrations to be **idempotent** — the built-in helpers are idempotent by construction. |
| 109 | + |
| 110 | +## Development and testing |
| 111 | + |
| 112 | +- `migrations.autoRun` (configuration PID `org.jahia.modules.javascript.modules.engine.migrations`, default `true`): set to `false` on development servers to log pending migrations at module start instead of running them. |
| 113 | +- 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/migrationTest.cy.ts` in the javascript-modules repository for a complete example (statuses, all five operations, skip/failure semantics). |
| 114 | +- To re-run a migration 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. |
0 commit comments