Skip to content

FEATURE: Resolve conflicts when applying a revision (skip, move back, remove) - #40

Draft
gradinarufelix wants to merge 11 commits into
mainfrom
feature-31-conflict-resolution-model
Draft

gradinarufelix wants to merge 11 commits into
mainfrom
feature-31-conflict-resolution-model

Conversation

@gradinarufelix

@gradinarufelix gradinarufelix commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Related: #31

First of the two PRs for #31 ("PR A" in the issue): the resolution model with __skip, __moveBack and __remove, rows for content moved to the page, the boundary and document-node rules, the JSON body, --resolutions for the CLI, abstract node types, and the removal of force. 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:

Row Choices
The node type no longer exists, or is abstract (decision 12) __skip
The node would be created, moved back or retyped where it is not allowed __skip
The node's parent in the revision no longer exists __skip
A variant was moved to another page since the revision __skip ("leave there"), __moveBack
A variant was moved to this page since the revision and is older than it (decision 9) __skip ("keep"), __remove
  • __skip leaves 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.
  • __remove is 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.
  • A variant moved away that is still inside a variant of a node moved away with it follows that node and gets no row of its own. A variant moved on its own, e.g. in another language than its parent, gets its own row.
  • A node whose variants need different resolutions (moved away in one language and moved here in another, or a node type problem together with a move) can only be skipped.
  • A variant only visible through a fallback dimension counts as missing and is created where the revision has it.
  • Different nodes can have the same path in different dimensions, and the variants of one node can have different paths. A skip covers what is inside the skipped node in the skipped node's own dimensions. Whether a variant moves along with an ancestor is decided by the ancestor's variants at the variant's own ancestor paths and by what each of them contains.
  • Moving back is judged by the variants it would move: a variant inside a skipped node stays where it is, so it neither blocks nor is moved. Node type problems count per variant, so only the variants that move and the variants inside them can keep a node from moving back; a row with a node type problem besides the move still only offers __skip.
  • 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, so a move back that becomes impossible is reported by the validation, not by staging.
  • Content moved into a node that was moved away gets a row only if the variant it is inside is moved back.
  • Content is not offered __moveBack when another node, in any dimension, has its place now, since Neos refuses to move a node to such a path.
  • Rows inside a skipped node are left out. Content that only a node moved back would bring to the page gets a row once that node is moved back.
  • The document node itself is never resolved (decision 3): drift on it is an error, and so is a resolution for it.

Resolutions that break the rules are rejected with an error, independent of applyWithoutAuthorizationChecks (decision 2):

  • a key that is neither a node of the revision nor content moved to its page;
  • an unsupported key, e.g. __newNodeType until PR B;
  • not exactly one resolution set to true;
  • __moveBack for content that was not moved away on its own, __remove for content that was not moved here;
  • __moveBack for content whose place another node has now;
  • __skip for a node with a variant that moves back along with its ancestor (decision 1);
  • __moveBack while 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 = []) returns rows, errors and isApplicable. 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.
  • Staging only takes content from another page for the variants that got __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.
  • The document itself stays where it is: the analysis compares every document variant of the revision with its live variant and refuses a revision whose document node was renamed since, and staging stops with an error when the document was moved after the analysis instead of moving it back.
  • The apply and diff actions answer 404 for a revision that does not belong to the given node.
  • The controller answers every case with 422 and a body {"rows": [...], "errors": [...]}. A row has identifier, label, nodeType (name and label), path (relative to the document), dimensions, problems (id and an English message), document (where content moved away is now), choices and the resolution it got so far. 403 uses the same shape.
  • Resolutions come as a JSON body, which Flow maps to the resolutions argument of applyAction().
  • ./flow revision:apply gets --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

  • The fetch helper sends {resolutions} as the JSON body of the apply request and reads {rows, errors} from 403 and 422.
  • A 422 with rows opens a dialog (the Neos UI 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.
  • The confirm() for forcing an apply and its two translation units are gone. New units in en and de cover the dialog, problems and choices.
  • The revision list is keyed by the document's context path, so switching documents starts a new list: no callback, dialog or state keeps the previous document, whose revision would now be answered with 404. The list closes the diff it opened when it is replaced. This is not click-tested; the document switch in the inspector is left to a maintainer, like the dialog.
  • Plugin.js is 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 $nodePath argument anymore; it takes the revision identifier and the resolutions.
  • RevisionService::checkRevisionForConflicts() is removed.
  • RevisionService::validateRevision() takes the resolutions and returns rows, errors and isApplicable instead of a list of messages.
  • RevisionNotApplicableException is built from rows and errors; getProblems() is replaced by getRows() and getErrors().
  • The apply action has no force argument 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 through RequestBodyParsingMiddleware and ActionRequestFactory with a JSON body, then through the controller with an account in the role Neos.Neos:Editor. The CLI cases ran ./flow revision:apply.

On the head a9218c2, in this order, with R0 a revision of a page with:

  • a list P with an item K in en and de;
  • a list A with item A1 only in en and a list B with item B1 only in de, A and B at the same path;
  • accordions G only in en and G' only in de at the same path, with an accordion item and a list P2 in both below them;
  • an accordion M in en and de with an accordion item T2 only in en and S2 only in de at the same path, a text X2 named differently in both (x in T2, x2 in S2) and a text N2 in T2 at the path X2 has in de;
  • on another page a list Q in en and de with an item Z in en and an item Z2 in de.
Case Result
R0 on the unchanged page, no resolutions 200, 0 variants staged
P moved to another page in en only (K en along), P de and K de stay 422, one row for P (en) offering __moveBack, no row for K; __moveBack gives 200 with the two en variants back and de untouched
P moved to another page in en (K en along), K de moved into Q de on its own 422, rows for P (en) and K (de), each movedAway; P __moveBack with K __skip is refused, both __moveBack give 200 and the page equals R0
P2 moved to another page on its own in en and de, Z2 (older than R0) moved into P2 de there 422, one row for P2 (en, de), no row for Z2. P2 __moveBack with G' __skip gives 200 without a row for Z2: only P2 en moves back, P2 de and Z2 stay. P2 __moveBack with Z2 __remove then gives 200 and the page equals R0
B1 edited in de, then R0 with A (en) __skip 200, 1 de variant staged: B1 is back to its title in R0, A and A1 are untouched
B moved to another page in de with B1 inside 422, one row for B only (movedAway, de), offering only __skip, since A has the path in en; __moveBack is refused with that reason, __skip gives 200
X2 moved to another page on its own in en and de, then M de with S2 inside; M __moveBack, S2 __skip, X2 __moveBack, B __skip as before 422 with two errors and nothing staged: S2 moves back along with M, and X2 cannot be moved back, because once the skip is gone its de variant moves too and N2 has that place; X2's row offers only __skip
CLI, the revisions from before the test for both pages Both pages equal their state before the test

On 07d0323, with a numbered list C with two items only in en:

Case Result
C moved to another page, no resolutions 422, one row for C: movedAway, choices __skip, __moveBack, the other page as document; no rows for its items
C __skip 200, 0 variants staged, both pages unchanged
C __moveBack, item 1 __skip 422: item 1 moves back along with C
An item of C given a node type that does not exist C offers only __skip; __moveBack is rejected
C __moveBack 200, the page equals the state of R0

On f02e7dc:

Case Result
The document moved in live between the analysis and staging 422 with the error, nothing published, no temporary workspace; after moving it back the page is unchanged
An existing text moved here from another page, new content created 422, one row for the text: movedHere, choices __skip, __remove; no row for the new content
Text __skip, then __remove 200 each: first the text stays and the new content is removed, then the text is removed and the page equals R0
Copy of R0 with a node type that does not exist (C), an abstract one and a document type in a collection, item 1 edited and item 3 added in live 422, one row each: nodeTypeMissing, nodeTypeMissing (abstract), nodeTypeNotAllowed; item 3 shows as moved here, since the copy is younger than it
All three __skip 200, 0 variants staged: item 1 keeps its edit, item 3 stays
Copy of R0 with a document node type that does not exist, document in two languages 422, 1 error; a resolution for the document adds its own error
Six broken resolutions in one request 422 with six errors, nothing applied
applyWithoutAuthorizationChecks: false, edits in en denied, C __moveBack 403, live unchanged, no temporary workspace left
Same setting, C __skip 200, 0 variants staged
CLI, C moved away, no file Prints the default (C moved back) and applies it; the page equals R0

On 026cdb3, with a list P with an item K in en and de, a text W in en on the page whose de variant is on another page, and a list Q in en and de on the other page with an item Z in en:

Case Result
R0 sent with the node of another page 404, nothing applied
A text moved to another page in live between the analysis and staging 422 with the error, nothing published; afterwards a movedAway row, and __moveBack restores the page
A list Y and its item Y1 created after R0, Z (older than R0) moved into Y 422, one row for Z: movedHere; none for Y and Y1
Z __skip, then __remove 200 each: first Y stays with Z and Y1 is removed, then Y and Z are removed and the page equals R0
W's de variant moved here from the other page 422, one row for W with only de as dimensions: movedHere; __remove removes W de and leaves W en
C moved to another page in en, copy of R0 with C and its items in an English preset that falls back to en 200 without rows: the three variants are created on the page, C and its items in en stay on the other page

The 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

  • Content created in a workspace before the revision and published after it gets a row as content moved here, since its creation date predates the revision.
  • A node whose variants need different resolutions can only be skipped, since a resolution applies to all variants of a node.
  • A row for a node whose parent cannot be placed stays next to the parent's row until the parent is skipped, e.g. for the items of a list that was moved away and whose node type no longer exists.
  • Content moved within the page is still moved back without a row, as before. If another node has its place now in another dimension, Neos refuses that move while staging, and the apply fails with 500 without changing anything.

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

Copy link
Copy Markdown
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant