{"record":{"id":"3a1fd4a630552923","repo":"nocobase/nocobase","slug":"flowmodels-move-source-and-target-must-be-sibling","errorCode":null,"errorMessage":"flowModels:move source and target must be sibling nodes under the same parent/subKey","messagePattern":"flowModels:move source and target must be sibling nodes under the same parent/subKey","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/plugins/@nocobase/plugin-flow-engine/src/server/repository.ts","lineNumber":2058,"sourceCode":"\n    const sourceInfo = await this.findSiblingInfo(sourceUid, transaction);\n    const targetInfo = await this.findSiblingInfo(targetUid, transaction);\n    if (!targetInfo) {\n      throw new Error('flowModels:move target is not attached to a parent');\n    }\n    const targetInstance = await this.model.findByPk(targetUid, { transaction });\n    const targetOptions = FlowModelRepository.optionsToJson(targetInstance?.get('options') || {});\n    const targetSubType = targetOptions.subType === 'object' ? 'object' : 'array';\n    if (targetInfo.parentUid === sourceUid || (await this.isAncestorOf(sourceUid, targetInfo.parentUid, transaction))) {\n      throw new Error('flowModels:move cycle detected');\n    }\n\n    if (sourceInfo?.parentUid === targetInfo.parentUid && sourceInfo.type === targetInfo.type) {\n      const siblingRows = await this.findSiblingSortRows(sourceInfo.parentUid, sourceInfo.type, transaction);\n      const sourceRow = siblingRows.find((row) => row.uid === sourceUid);\n      const targetIndex = siblingRows.findIndex((row) => row.uid === targetUid);\n      if (!sourceRow || targetIndex === -1) {\n        throw new Error('flowModels:move source and target must be sibling nodes under the same parent/subKey');\n      }\n\n      const rowsWithoutSource = siblingRows.filter((row) => row.uid !== sourceUid);\n      const insertIndex = rowsWithoutSource.findIndex((row) => row.uid === targetUid);\n      rowsWithoutSource.splice(position === 'after' ? insertIndex + 1 : insertIndex, 0, sourceRow);\n      await this.writeSiblingSorts(\n        sourceInfo.parentUid,\n        rowsWithoutSource.map((row) => row.uid),\n        transaction,\n      );\n      return await this.findModelById(sourceUid, { transaction });\n    }\n\n    await this.normalizeSiblingSorts(sourceInfo, transaction);\n    await this.normalizeSiblingSorts(targetInfo, transaction);\n    await this.updateModelParentOptions(sourceUid, targetInfo.parentUid, targetInfo.type, targetSubType, transaction);\n\n    await this.insertSingleNode(","sourceCodeStart":2040,"sourceCodeEnd":2076,"githubUrl":"https://github.com/nocobase/nocobase/blob/fa42722fefe44265490dff2c27d79e2882bce4fa/packages/plugins/@nocobase/plugin-flow-engine/src/server/repository.ts#L2040-L2076","documentation":"Thrown by FlowModelRepository.move in the same-parent fast path: both source and target share a parentUid and type (subKey), but the sibling sort rows do not actually contain both nodes — sourceRow is missing or targetIndex is -1. The in-memory sort index and the tree table are out of sync.","triggerScenarios":"Calling move between two nodes whose parentUid/type match per findSiblingInfo, but whose sort rows were never written (corrupt sort table), were written with different uids (case/whitespace mismatch), or were deleted concurrently.","commonSituations":"Manual DB edits or imports that populate the tree table but skip the sibling sort rows; a crash between writing tree paths and sort rows outside one transaction; duplicate uid entries with differing trailing whitespace.","solutions":["Normalize sibling sorts first (e.g. call move once with sourceId === targetId, which triggers normalizeSiblingSorts and returns null), then retry the real move.","Re-attach the affected children under the parent to rebuild both tree and sort rows.","Add a repair routine that diffs findSiblingSortRows against findSiblingInfo and writes missing rows via writeSiblingSorts."],"exampleFix":"// before\nawait repo.move({ sourceId, targetId, position: 'after' }); // throws when sort rows are stale\n// after\ntry {\n  await repo.move({ sourceId, targetId, position: 'after' });\n} catch (e) {\n  if (String(e.message).includes('must be sibling nodes')) {\n    await repo.move({ sourceId, targetId: sourceId, position: 'after' }); // normalize sorts\n    await repo.move({ sourceId, targetId, position: 'after' });\n  } else throw e;\n}","handlingStrategy":"try-catch","validationCode":"const rows = await repo.findSiblingSortRows(parentUid, subKey);\nif (!rows.some((r) => r.uid === sourceId) || !rows.some((r) => r.uid === targetId)) {\n  await repo.move({ sourceId, targetId: sourceId, position: 'after' }); // triggers normalizeSiblingSorts\n}\nawait repo.move({ sourceId, targetId, position });","typeGuard":null,"tryCatchPattern":"try {\n  await repo.move(opts);\n} catch (e) {\n  if (String(e.message).includes('must be sibling nodes under the same parent/subKey')) {\n    await repo.move({ sourceId: opts.sourceId, targetId: opts.sourceId, position: 'after' });\n    await repo.move(opts);\n  } else throw e;\n}","preventionTips":["Never edit the tree/sort tables outside repository APIs or transactions","Rebuild sibling sorts after imports, restores, or bulk deletes","Detect drift by diffing sort rows against findSiblingInfo periodically"],"tags":["flow-engine","tree","sort-index","data-integrity"],"backgroundTag":"sort-index-out-of-sync","analyzedSha":"fa42722fefe44265490dff2c27d79e2882bce4fa","analyzedAt":"2026-09-01T00:54:31.202Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-08T05:18:18.240Z"}