Repository navigation
FEATURE: Resolve conflicts when applying a revision (skip, move back, remove) - #40
Draft
gradinarufelix wants to merge 11 commits into
Draft
gradinarufelix wants to merge 11 commits into
gradinarufelix wants to merge 11 commits into
Conversation
Applying a revision no longer stops at the first problem or asks to force it. Every node that needs a decision becomes a row, and the apply request can carry one resolution per node identifier: - __skip leaves the node and everything inside it as it is in live; - __moveBack moves content that was moved to another page back; - __remove removes content that was moved to the page after the revision was created, instead of removing it silently. Rows cover node types that no longer exist or are abstract, nodes no longer allowed where they would be created, moved back or retyped, content moved to another page and content moved to the page. The document node itself is never resolved. Resolutions outside the revision and its page, and combinations that would move skipped content or rewrite a node type that no longer exists, are rejected. RevisionService::validateRevision() returns the rows and the errors, and applyRevision() takes the resolutions. The controller answers every case with 422 and a JSON body of rows and errors; the 409 and the force argument are gone, as is checkRevisionForConflicts(). revision:apply gets --resolutions=<file> and keeps moving content back and removing content moved here unless the file says otherwise. Related: #31
The inspector sends the resolutions as a JSON body. A 422 with rows opens a dialog that lists every row with its choices, skipping first, and applies again once each row has one. Each answer lists all rows that still matter, with the resolutions they already got, so the dialog shows the latest answer. The confirm to force an apply is gone. Plugin.js is rebuilt with Node 14.21.3, esbuild 0.17.19 and neos-ui-extensibility 8.4.4. Related: #31
findNodeTypeProblem() fills the map by reference, and PHPStan checks the type it leaves behind against array<string, string>. Related: #31
The review of the resolution model found these gaps: - The apply action never checked that the revision belongs to the node it was given, and imported the revision under that node's parent, so a revision of one page could move that page under another one. The target is now always the revision's own document at its current place: applyRevision() has no path argument anymore, and the apply and diff actions answer 404 for a revision of another node. - Staging moved every node whose path differed. Content on another page is now only taken with the variants that got __moveBack; content moved there after the check stops the apply with an error. - Content moved into content created after the revision was removed with it without a row. The check now looks inside such content, and keeping the older content keeps the newer content around it. - Moved-away, moved-here and moves-along were decided per node identifier, so a variant moved in one language could follow a move in another, or be removed without a row. They are now decided per variant; a node whose variants need different resolutions can only be skipped. - A variant only visible through a fallback dimension counted as moved away. It now counts as missing and is created where the revision has it, instead of at the fallback's place, whose move would have taken the fallback's child nodes along. Related: #31
An error cannot be fixed with a choice in the dialog, so applying again only repeated it. Related: #31
…ject createNode() for an identifier that the context already knows as a fallback node returns that cached node, not the new variant, so staging kept working on the fallback and stopped at the check for content from another page, leaving the new node data behind without a workspace. createVariantForContext() turns the known node into the new variant. The variant is created at the fallback's place, so its node data is set to the path of the revision, which does not take the fallback's child nodes along like a move would. Related: #31
Different nodes can have the same path in different dimensions. A __skip in one dimension excluded the nodes below the same path in another dimension from staging, and the node found at an ancestor path could be the wrong one when deciding whether content moves along with it. Skipped paths now apply per dimensions hash, a row keeps the variants that are not inside a skipped node, and every node of the revision at an ancestor path is checked, whose subtree holds variants. The document itself is also guarded: the analysis refuses a revision that would put the document elsewhere, which only a renamed document does, and staging stops when the document was moved after the analysis instead of moving it back. Related: #31
The inspector kept one list across documents, and its apply and diff callbacks kept the first document, so a revision of the current document was sent with the previous one and answered with 404. The list is keyed by the document's context path, and closes the diff it opened when it is replaced. Plugin.js is rebuilt with Node 14.21.3, esbuild 0.17.19 and neos-ui-extensibility 8.4.4. Related: #31
Neos moves a node only to a path that no other node has in any dimension, so moving content back where another node took its place, e.g. in another language, failed while staging and answered with 500. The analysis now leaves __moveBack out for such content and says why when it is sent anyway. Related: #31
Three places still decided per node identifier before the skips of a dimension were known: - Whether a variant moves along with an ancestor was checked against all variants of the ancestor. Only the ancestor variants at the variant's own ancestor paths count now, each with its own subtree. - Moving back was judged, and allowed variants were collected, from all variants a node moved away with, although a variant inside a skipped node stays where it is. Both now follow the skips and use only the variants that would be moved. - The document's place was compared once, for one variant. Every document variant of the revision is compared with its live variant now, and the error says that the document node was renamed. Related: #31
- Content moved into a node that was moved away remembers the variant it is inside, and gets a row only if that variant is moved back. - A skip that a move back rules out frees variants of other nodes. The variants to move, their obstacles and the skips are judged again until nothing changes, and a move back that becomes impossible is an error of the validation, not of staging. - Node type problems count per variant: only the variants that move, and the variants inside them, can keep a node from moving back. A row that has a node type problem besides the move still only offers skip. Related: #31
gradinarufelix
marked this pull request as draft
September 29, 2026 09:09
Contributor
Author
|
Parked: development of the conflict resolution work is paused for now. This PR is complete from the implementation side (head a9218c2, CI green, four independent review passes, ILF test tables in the description) and is converted to draft so it is not merged by accident. Before merging when work resumes: rebase onto main, run CI, and do the manual inspector check (switch pages, then show a diff and apply; provoke one resolution dialog). The remaining work is tracked in the parking issue linked from #31. |
12 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Related: #31
First of the two PRs for #31 ("PR A" in the issue): the resolution model with
__skip,__moveBackand__remove, rows for content moved to the page, the boundary and document-node rules, the JSON body,--resolutionsfor the CLI, abstract node types, and the removal offorce. Retyping, restoring into another collection, property renames, candidates, tethered nodes and diff warnings follow in PR B, which closes the issue.Model
Applying a revision no longer stops at the first problem, and there is nothing left to force. Every node that needs a decision becomes a row. A row is keyed by node identifier and lists the variants (dimension combinations) concerned; the checks behind it look at each variant. The apply request carries at most one resolution per node identifier, with exactly one key set to
true:__skip__skip__skip__skip("leave there"),__moveBack__skip("keep"),__remove__skipleaves the node and everything inside it as it is in live (decision 1). Its revision descendants are not staged, and the removal of nodes missing in the revision does not touch it or anything inside it.__removeis the explicit choice for content moved here. Without a resolution, such content is no longer removed silently. Content created after the revision is still removed without a row, but content moved into it is found as well; keeping that content also keeps the newer content around it, while the newer content's other children are removed as before.__skip.__moveBackwhen another node, in any dimension, has its place now, since Neos refuses to move a node to such a path.Resolutions that break the rules are rejected with an error, independent of
applyWithoutAuthorizationChecks(decision 2):__newNodeTypeuntil PR B;true;__moveBackfor content that was not moved away on its own,__removefor content that was not moved here;__moveBackfor content whose place another node has now;__skipfor a node with a variant that moves back along with its ancestor (decision 1);__moveBackwhile the moved content has a node type that does not resolve, or contains a node of the revision with a node type problem (decision 7). A move rewrites the node data inside it, which would replace a dead node type with the fallback node type. PR B's retype lifts this.Every resolution that answers a row is logged with the node identifier, its node type and the choice. A resolution for a node without a row, which only the API and the CLI file can send, is applied but not logged.
Server
RevisionService::validateRevision(Revision $revision, array $resolutions = [])returnsrows,errorsandisApplicable. Reading the revision writes nothing.resolveRevision()is the one code path that finds the rows, checks the resolutions and plans the apply.applyRevision($identifier, array $resolutions = [])always applies to the revision's own document, at the place it has now; it has no path argument anymore. It reads the revision with asset restore and runs the same analysis again.__moveBack. Content moved to another page after the analysis stops the apply with an error, so a parallel publish cannot have content pulled back without a resolution. Moves within the document are restored as before.{"rows": [...], "errors": [...]}. A row hasidentifier,label,nodeType(name and label),path(relative to the document),dimensions,problems(idand an Englishmessage),document(where content moved away is now),choicesand theresolutionit got so far. 403 uses the same shape.resolutionsargument ofapplyAction()../flow revision:applygets--resolutions=<file>with the same structure. Rows the file leaves open keep the previous behaviour (decision 11): content moved away is moved back, content moved here is removed, as far as the row offers it. Both are printed before the confirmation. Drift without a resolution is refused, with the rows printed as a table.Inspector
{resolutions}as the JSON body of the apply request and reads{rows, errors}from 403 and 422.Dialog), also when the apply comes from the diff view. Each row shows its label, node type, dimensions and problem, and its choices as buttons, skipping first. Apply is enabled once every row has a choice. Each answer lists all rows that still matter with the resolution they already got, so a further 422 reopens the dialog with the latest rows, prefilled. While there are errors, the dialog shows them and offers no apply, since no choice in it fixes them.confirm()for forcing an apply and its two translation units are gone. New units in en and de cover the dialog, problems and choices.Plugin.jsis rebuilt with Node 14.21.3, esbuild 0.17.19 and neos-ui-extensibility 8.4.4; two builds produced the same bytes.Breaking changes, for the beta.2 notes
RevisionService::applyRevision()has no$nodePathargument anymore; it takes the revision identifier and the resolutions.RevisionService::checkRevisionForConflicts()is removed.RevisionService::validateRevision()takes the resolutions and returnsrows,errorsandisApplicableinstead of a list of messages.RevisionNotApplicableExceptionis built from rows and errors;getProblems()is replaced bygetRows()andgetErrors().forceargument and no longer answers 409. Its 422 and 403 bodies are objects instead of lists of messages. The apply and diff actions answer 404 for a revision of another node.Test
Environment: Neos 8.4.7, PHP 8.3 and MariaDB, with the branch files copied into a test installation that has two languages, English presets that fall back to
en, and runs 2.0.0-beta.1. Every apply went throughRequestBodyParsingMiddlewareandActionRequestFactorywith a JSON body, then through the controller with an account in the roleNeos.Neos:Editor. The CLI cases ran./flow revision:apply.On the head a9218c2, in this order, with R0 a revision of a page with:
enandde;enand a list B with item B1 only inde, A and B at the same path;enand G' only indeat the same path, with an accordion item and a list P2 in both below them;enanddewith an accordion item T2 only inenand S2 only indeat the same path, a text X2 named differently in both (xin T2,x2in S2) and a text N2 in T2 at the path X2 has inde;enanddewith an item Z inenand an item Z2 inde.enonly (Kenalong), Pdeand Kdestayen) offering__moveBack, no row for K;__moveBackgives 200 with the twoenvariants back anddeuntoucheden(Kenalong), Kdemoved into Qdeon its ownen) and K (de), eachmovedAway; P__moveBackwith K__skipis refused, both__moveBackgive 200 and the page equals R0enandde, Z2 (older than R0) moved into P2dethereen,de), no row for Z2. P2__moveBackwith G'__skipgives 200 without a row for Z2: only P2enmoves back, P2deand Z2 stay. P2__moveBackwith Z2__removethen gives 200 and the page equals R0de, then R0 with A (en)__skipdevariant staged: B1 is back to its title in R0, A and A1 are untoucheddewith B1 insidemovedAway,de), offering only__skip, since A has the path inen;__moveBackis refused with that reason,__skipgives 200enandde, then Mdewith S2 inside; M__moveBack, S2__skip, X2__moveBack, B__skipas beforedevariant moves too and N2 has that place; X2's row offers only__skipOn 07d0323, with a numbered list C with two items only in
en:movedAway, choices__skip,__moveBack, the other page asdocument; no rows for its items__skip__moveBack, item 1__skip__skip;__moveBackis rejected__moveBackOn f02e7dc:
movedHere, choices__skip,__remove; no row for the new content__skip, then__removenodeTypeMissing,nodeTypeMissing(abstract),nodeTypeNotAllowed; item 3 shows as moved here, since the copy is younger than it__skipapplyWithoutAuthorizationChecks: false, edits inendenied, C__moveBack__skipOn 026cdb3, with a list P with an item K in
enandde, a text W inenon the page whosedevariant is on another page, and a list Q inenanddeon the other page with an item Z inen:movedAwayrow, and__moveBackrestores the pagemovedHere; none for Y and Y1__skip, then__removedevariant moved here from the other pagedeasdimensions:movedHere;__removeremoves Wdeand leaves Wenen, copy of R0 with C and its items in an English preset that falls back toenenstay on the other pageThe system log has one line per resolution that answered a row; the skips of G' and A, which had no row, left none. No temporary workspace and no node data without a workspace were left after any case. Afterwards the database snapshot and the package files were restored.
Some CLI calls exited with code 1 after "Revision applied": an integration of the test installation resolves routes at shutdown without an initialized security context. The applies themselves were complete.
Not tested: the inspector click path and the document switch, which need a backend login. Both are left to a maintainer.
Known limitations