Skip to content

CreateOrUpdateNode cannot change a node's MainNode: IsNoOpUpsert never compares it and UpdateAccordingToSourceNode keeps the stored one #2631

Closed

Description

@rbuergi

What happens

An upsert (IMeshService.CreateOrUpdateNode → HandleCreateOrUpdateNodeRequest) whose only difference from the stored node is MainNode is treated as a no-op and skipped: MeshExtensions.IsNoOpUpsert compares Name / NodeType / Icon / Category / Description / Order / State / PreRenderedHtml / ExcludeFromContext / Content — never MainNode. And when the upsert does take the update branch, UpdateAccordingToSourceNode builds state with { Name, NodeType, Icon, … Content } and keeps the stored MainNode. So a non-satellite node's MainNode can never be moved by an upsert, while GetMeshNodeStream(path).Update(n => n with { MainNode = … }) (what the patch tool uses) persists it fine.

Measured

MeshWeaver.Plugins #839 mirrors an app tile's click target into the record's MainNode and heals existing records through the Store's RefreshAppTiles sweep, which writes with CreateOrUpdateNode. On 2026-08-28 the sweep reported 1390 of 1443 record(s) refreshed on memex (then 839 on a re-run) and 108 of 133 on systemorph — and not one record's mainNode changed (e.g. rbuergi/_App/HomeAssistant stayed at version 1). A direct patch {"mainNode": …} on the same record persisted (v4 → v5).

Expected

Either the upsert carries MainNode (compare it in IsNoOpUpsert, merge it in UpdateAccordingToSourceNode — the satellite normalisation already runs before both), or the contract documents that MainNode is not upsertable and the request is rejected rather than silently reported as processed.

Workaround shipped in MeshWeaver.Plugins #841 (AppTileRefresh.WriteRecord: upsert, then the node stream for MainNode).

🤖 Generated with Claude Code

https://claude.ai/code/session_01ETgRRMRVzYMg1WU4pMywed

Activity

  1. rbuergi commented on Aug 29, 2026

    @rbuergi
    ContributorAuthor

    Fix in #2652.

    Both sites confirmed as described. A third was found while confirming them: PackageInstaller.ScalarsUnchanged is an explicit mirror of the same field list and gates the write before the upsert is asked, so without it a node-repo install still could not move a MainNode.

    The self-path trap is real and measured — the naive source.MainNode ?? state.MainNode fix demotes a stored satellite to a main node on both the merge and the compare path (2 of the PR's 5 tests go red under that mutation, while only 1 goes red on unfixed code). The rule shipped is MeshNode.HasExplicitMainNode: apply the source's MainNode only when it names something other than the node itself. Its one limitation — an upsert cannot turn a satellite back into a main node — is documented on the property and in the PR body; no caller needs it today.

    🤖 Generated with Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions